Merge branch 'develop-2.4' into 1898-activity-logging

This commit is contained in:
Jeremy Stretch 2018-06-21 16:53:15 -04:00
commit 9d419de9dc
20 changed files with 294 additions and 26 deletions

View File

@ -1,7 +1,5 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from collections import OrderedDict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag from taggit.models import Tag
@ -63,9 +61,9 @@ class SiteSerializer(CustomFieldModelSerializer):
model = Site model = Site
fields = [ fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes',
'count_devices', 'count_circuits', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
] ]

View File

@ -115,8 +115,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'tags', 'contact_email', 'comments', 'tags',
] ]
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
@ -129,7 +129,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'time_zone': "Local time zone", 'time_zone': "Local time zone",
'description': "Short description (will appear in sites list)", 'description': "Short description (will appear in sites list)",
'physical_address': "Physical location of the building (e.g. for GPS)", 'physical_address': "Physical location of the building (e.g. for GPS)",
'shipping_address': "If different from the physical address" 'shipping_address': "If different from the physical address",
'latitude': "Latitude in decimal format (xx.yyyyyy)",
'longitude': "Longitude in decimal format (xx.yyyyyy)"
} }

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-21 18:45
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0058_relax_rack_naming_constraints'),
]
operations = [
migrations.AddField(
model_name='site',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
),
migrations.AddField(
model_name='site',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View File

@ -8,7 +8,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0058_relax_rack_naming_constraints'), ('dcim', '0059_site_latitude_longitude'),
] ]
operations = [ operations = [

View File

@ -136,6 +136,18 @@ class Site(ChangeLoggedModel, CustomFieldModel):
max_length=200, max_length=200,
blank=True blank=True
) )
latitude = models.DecimalField(
max_digits=8,
decimal_places=6,
blank=True,
null=True
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True
)
contact_name = models.CharField( contact_name = models.CharField(
max_length=50, max_length=50,
blank=True blank=True
@ -166,7 +178,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
serializer = 'dcim.api.serializers.SiteSerializer' serializer = 'dcim.api.serializers.SiteSerializer'
csv_headers = [ csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
] ]
class Meta: class Meta:
@ -191,6 +203,8 @@ class Site(ChangeLoggedModel, CustomFieldModel):
self.description, self.description,
self.physical_address, self.physical_address,
self.shipping_address, self.shipping_address,
self.latitude,
self.longitude,
self.contact_name, self.contact_name,
self.contact_phone, self.contact_phone,
self.contact_email, self.contact_email,

View File

@ -3,11 +3,11 @@ from __future__ import unicode_literals
# Models which support custom fields # Models which support custom fields
CUSTOMFIELD_MODELS = ( CUSTOMFIELD_MODELS = (
'provider', 'circuit', # Circuits 'provider', 'circuit', # Circuits
'site', 'rack', 'devicetype', 'device', # DCIM 'site', 'rack', 'devicetype', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'tenant', # Tenancy 'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization 'cluster', 'virtualmachine', # Virtualization
) )
# Custom field types # Custom field types

View File

@ -301,7 +301,7 @@ class AvailableIPSerializer(serializers.Serializer):
# Services # Services
# #
class ServiceSerializer(ValidatedModelSerializer): class ServiceSerializer(CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
@ -315,6 +315,6 @@ class ServiceSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created', 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description',
'last_updated', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -426,6 +426,10 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
class ServiceFilter(django_filters.FilterSet): class ServiceFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label='Device (ID)', label='Device (ID)',
@ -450,3 +454,9 @@ class ServiceFilter(django_filters.FilterSet):
class Meta: class Meta:
model = Service model = Service
fields = ['name', 'protocol', 'port'] fields = ['name', 'protocol', 'port']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
return queryset.filter(qs_filter)

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import MultipleObjectsReturned
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import Count from django.db.models import Count
from taggit.forms import TagField from taggit.forms import TagField
@ -15,7 +16,9 @@ from utilities.forms import (
SlugField, add_blank_choice, SlugField, add_blank_choice,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES from .constants import (
IP_PROTOCOL_CHOICES, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
)
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
IP_FAMILY_CHOICES = [ IP_FAMILY_CHOICES = [
@ -914,7 +917,7 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Services # Services
# #
class ServiceForm(BootstrapMixin, forms.ModelForm): class ServiceForm(BootstrapMixin, CustomFieldForm):
class Meta: class Meta:
model = Service model = Service
@ -940,3 +943,28 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
) )
else: else:
self.fields['ipaddresses'].choices = [] self.fields['ipaddresses'].choices = []
class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Service
q = forms.CharField(
required=False,
label='Search'
)
protocol = forms.ChoiceField(
choices=add_blank_choice(IP_PROTOCOL_CHOICES),
required=False
)
port = forms.IntegerField(
required=False
)
class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Service.objects.all(), widget=forms.MultipleHiddenInput)
protocol = forms.ChoiceField(choices=add_blank_choice(IP_PROTOCOL_CHOICES), required=False)
port = forms.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']

View File

@ -831,7 +831,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class Service(ChangeLoggedModel): class Service(ChangeLoggedModel, CustomFieldModel):
""" """
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
optionally be tied to one or more specific IPAddresses belonging to its parent. optionally be tied to one or more specific IPAddresses belonging to its parent.
@ -871,6 +871,11 @@ class Service(ChangeLoggedModel):
max_length=100, max_length=100,
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
serializer = 'ipam.api.serializers.ServiceSerializer' serializer = 'ipam.api.serializers.ServiceSerializer'
@ -880,6 +885,9 @@ class Service(ChangeLoggedModel):
def __str__(self): def __str__(self):
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
def get_absolute_url(self):
return reverse('ipam:service', args=[self.pk])
@property @property
def parent(self): def parent(self):
return self.device or self.virtual_machine return self.device or self.virtual_machine

View File

@ -6,7 +6,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface from dcim.models import Interface
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ToggleColumn from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """ RIR_UTILIZATION = """
<div class="progress"> <div class="progress">
@ -401,3 +401,19 @@ class VLANMemberTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface model = Interface
fields = ('parent', 'name', 'untagged', 'actions') fields = ('parent', 'name', 'untagged', 'actions')
#
# Services
#
class ServiceTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
viewname='ipam:service',
args=[Accessor('pk')]
)
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'port', 'description')

View File

@ -95,6 +95,10 @@ urlpatterns = [
url(r'^vlans/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), url(r'^vlans/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
# Services # Services
url(r'^services/$', views.ServiceListView.as_view(), name='service_list'),
url(r'^services/edit/$', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
url(r'^services/delete/$', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
url(r'^services/(?P<pk>\d+)/$', views.ServiceView.as_view(), name='service'),
url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
url(r'^services/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), url(r'^services/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),

View File

@ -923,6 +923,25 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Services # Services
# #
class ServiceListView(ObjectListView):
queryset = Service.objects.select_related('device', 'virtual_machine')
filter = filters.ServiceFilter
filter_form = forms.ServiceFilterForm
table = tables.ServiceTable
template_name = 'ipam/service_list.html'
class ServiceView(View):
def get(self, request, pk):
service = get_object_or_404(Service, pk=pk)
return render(request, 'ipam/service.html', {
'service': service,
})
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_service' permission_required = 'ipam.add_service'
model = Service model = Service
@ -936,9 +955,6 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine']) obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
return obj return obj
def get_return_url(self, request, obj):
return obj.parent.get_absolute_url()
class ServiceEditView(ServiceCreateView): class ServiceEditView(ServiceCreateView):
permission_required = 'ipam.change_service' permission_required = 'ipam.change_service'
@ -947,3 +963,22 @@ class ServiceEditView(ServiceCreateView):
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service' permission_required = 'ipam.delete_service'
model = Service model = Service
class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_service'
cls = Service
queryset = Service.objects.all()
filter = filters.ServiceFilter
table = tables.ServiceTable
form = forms.ServiceBulkEditForm
default_return_url = 'ipam:service_list'
class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_service'
cls = Service
queryset = Service.objects.all()
filter = filters.ServiceFilter
table = tables.ServiceTable
default_return_url = 'ipam:service_list'

View File

@ -187,6 +187,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>GPS Coordinates</td>
<td>
{% if site.latitude and site.longitude %}
{{ site.latitude }}, {{ site.longitude }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Contact Name</td> <td>Contact Name</td>
<td> <td>

View File

@ -27,6 +27,8 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.physical_address %} {% render_field form.physical_address %}
{% render_field form.shipping_address %} {% render_field form.shipping_address %}
{% render_field form.latitude %}
{% render_field form.longitude %}
{% render_field form.contact_name %} {% render_field form.contact_name %}
{% render_field form.contact_phone %} {% render_field form.contact_phone %}
{% render_field form.contact_email %} {% render_field form.contact_email %}

View File

@ -286,6 +286,11 @@
{% endif %} {% endif %}
<a href="{% url 'ipam:vlangroup_list' %}">VLAN Groups</a> <a href="{% url 'ipam:vlangroup_list' %}">VLAN Groups</a>
</li> </li>
<li class="divider"></li>
<li class="dropdown-header">Services</li>
<li>
<a href="{% url 'ipam:service_list' %}">Services</a>
</li>
</ul> </ul>
</li> </li>
<li class="dropdown{% if request.path|contains:'/virtualization/' %} active{% endif %}"> <li class="dropdown{% if request.path|contains:'/virtualization/' %} active{% endif %}">

View File

@ -1,5 +1,7 @@
<tr> <tr>
<td>{{ service.name }}</td> <td>
<a href="{{ service.get_absolute_url }}">{{ service.name }}</a>
</td>
<td> <td>
{{ service.get_protocol_display }}/{{ service.port }} {{ service.get_protocol_display }}/{{ service.port }}
</td> </td>

View File

@ -0,0 +1,88 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:service_list' %}">Services</a></li>
<li><a href="{{ service.parent.get_absolute_url }}">{{ service.parent }}</a></li>
<li>{{ service }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:service_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search Services" />
<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>
{% if perms.dcim.change_service %}
<div class="pull-right">
<a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this service
</a>
</div>
{% endif %}
<h1>{% block title %}{{ service }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=service %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Service</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Name</td>
<td>{{ service.name }}</td>
</tr>
<tr>
<td>Parent</td>
<td>
<a href="{{ service.parent.get_absolute_url }}">{{ service.parent }}</a>
</td>
</tr>
<tr>
<td>Protocol</td>
<td>{{ service.get_protocol_display }}</td>
</tr>
<tr>
<td>Port</td>
<td>{{ service.port }}</td>
</tr>
<tr>
<td>IP Addresses</td>
<td>
{% for ipaddress in service.ipaddresses.all %}
<a href="{{ ipaddress.get_absolute_url }}">{{ ipaddress }}</a><br />
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if service.description %}
{{ service.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
{% with service.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
</div>
</div>
{% endblock %}

View File

@ -32,4 +32,12 @@
{% render_field form.description %} {% render_field form.description %}
</div> </div>
</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 %} {% endblock %}

View File

@ -0,0 +1,13 @@
{% extends '_base.html' %}
{% block content %}
<h1>{% block title %}Services{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:service_bulk_edit' bulk_delete_url='ipam:service_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}