Merge pull request #2198 from digitalocean/1898-activity-logging

Closes #1898: Change logging
This commit is contained in:
Jeremy Stretch 2018-06-25 13:36:48 -04:00 committed by GitHub
commit ffcbc54522
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 3170 additions and 1753 deletions

View File

@ -44,6 +44,14 @@ BASE_PATH = 'netbox/'
--- ---
## CHANGELOG_RETENTION
Default: 90
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain changes in the database indefinitely. (Warning: This will greatly increase database size over time.)
---
## CORS_ORIGIN_ALLOW_ALL ## CORS_ORIGIN_ALLOW_ALL
Default: False Default: False

View File

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0011_tags'),
]
operations = [
migrations.AddField(
model_name='circuittype',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='circuittype',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='circuit',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='circuit',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='provider',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='provider',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -9,12 +9,12 @@ from taggit.managers import TaggableManager
from dcim.constants import STATUS_CLASSES from dcim.constants import STATUS_CLASSES
from dcim.fields import ASNField from dcim.fields import ASNField
from extras.models import CustomFieldModel from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel from utilities.models import ChangeLoggedModel
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
@python_2_unicode_compatible @python_2_unicode_compatible
class Provider(CreatedUpdatedModel, CustomFieldModel): class Provider(ChangeLoggedModel, CustomFieldModel):
""" """
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider. stores information pertinent to the user's relationship with the Provider.
@ -59,9 +59,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
serializer = 'circuits.api.serializers.ProviderSerializer' serializer = 'circuits.api.serializers.ProviderSerializer'
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -86,7 +85,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class CircuitType(models.Model): class CircuitType(ChangeLoggedModel):
""" """
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band". "Long Haul," "Metro," or "Out-of-Band".
@ -99,6 +98,7 @@ class CircuitType(models.Model):
unique=True unique=True
) )
serializer = 'circuits.api.serializers.CircuitTypeSerializer'
csv_headers = ['name', 'slug'] csv_headers = ['name', 'slug']
class Meta: class Meta:
@ -118,7 +118,7 @@ class CircuitType(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Circuit(CreatedUpdatedModel, CustomFieldModel): class Circuit(ChangeLoggedModel, CustomFieldModel):
""" """
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device
@ -173,12 +173,11 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
serializer = 'circuits.api.serializers.CircuitSerializer'
csv_headers = [ csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
] ]
serializer = 'circuits.api.serializers.CircuitSerializer'
class Meta: class Meta:
ordering = ['provider', 'cid'] ordering = ['provider', 'cid']
unique_together = ['provider', 'cid'] unique_together = ['provider', 'cid']

View File

@ -9,6 +9,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Circuit, CircuitType, Provider from .models import Circuit, CircuitType, Provider
CIRCUITTYPE_ACTIONS = """ CIRCUITTYPE_ACTIONS = """
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.circuit.change_circuittype %} {% if perms.circuit.change_circuittype %}
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views from . import views
from .models import Circuit, CircuitType, Provider
app_name = 'circuits' app_name = 'circuits'
urlpatterns = [ urlpatterns = [
@ -16,6 +18,7 @@ urlpatterns = [
url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'), url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
url(r'^providers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
# Circuit types # Circuit types
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
@ -23,6 +26,7 @@ urlpatterns = [
url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
url(r'^circuit-types/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits # Circuits
url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
@ -33,6 +37,7 @@ urlpatterns = [
url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'), url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
url(r'^circuits/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations # Circuit terminations

View File

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-05-30 17:30
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('dcim', '0058_relax_rack_naming_constraints'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='created',
field=models.DateField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='devicetype',
name='last_updated',
field=models.DateTimeField(auto_now=True),
),
]

View File

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

View File

@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0059_site_latitude_longitude'),
]
operations = [
migrations.AddField(
model_name='devicerole',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='devicerole',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='devicetype',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='devicetype',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='manufacturer',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='manufacturer',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='platform',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='platform',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='rackgroup',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='rackgroup',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='rackreservation',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='rackrole',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='rackrole',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='region',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='region',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='virtualchassis',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='virtualchassis',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='device',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='device',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='rack',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='rack',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='rackreservation',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='site',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='site',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -18,22 +18,48 @@ from taggit.managers import TaggableManager
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
from circuits.models import Circuit from circuits.models import Circuit
from extras.models import CustomFieldModel from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from extras.models import CustomFieldModel, ObjectChange
from extras.rpc import RPC_CLIENTS from extras.rpc import RPC_CLIENTS
from utilities.fields import ColorField, NullableCharField from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .constants import * from .constants import *
from .fields import ASNField, MACAddressField from .fields import ASNField, MACAddressField
from .querysets import InterfaceQuerySet from .querysets import InterfaceQuerySet
class ComponentModel(models.Model):
class Meta:
abstract = True
def get_component_parent(self):
raise NotImplementedError(
"ComponentModel must implement get_component_parent()"
)
def log_change(self, user, request_id, action):
"""
Log an ObjectChange including the parent Device/VM.
"""
ObjectChange(
user=user,
request_id=request_id,
changed_object=self,
related_object=self.get_component_parent(),
action=action,
object_data=serialize_object(self)
).save()
# #
# Regions # Regions
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class Region(MPTTModel): class Region(MPTTModel, ChangeLoggedModel):
""" """
Sites can be grouped within geographic Regions. Sites can be grouped within geographic Regions.
""" """
@ -53,6 +79,7 @@ class Region(MPTTModel):
unique=True unique=True
) )
serializer = 'dcim.api.serializers.RegionSerializer'
csv_headers = ['name', 'slug', 'parent'] csv_headers = ['name', 'slug', 'parent']
class MPTTMeta: class MPTTMeta:
@ -81,7 +108,7 @@ class SiteManager(NaturalOrderByManager):
@python_2_unicode_compatible @python_2_unicode_compatible
class Site(CreatedUpdatedModel, CustomFieldModel): class Site(ChangeLoggedModel, CustomFieldModel):
""" """
A Site represents a geographic location within a network; typically a building or campus. The optional facility A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
@ -174,13 +201,12 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
objects = SiteManager() objects = SiteManager()
tags = TaggableManager() tags = TaggableManager()
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', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
] ]
serializer = 'dcim.api.serializers.SiteSerializer'
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -245,7 +271,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class RackGroup(models.Model): class RackGroup(ChangeLoggedModel):
""" """
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
@ -261,9 +287,8 @@ class RackGroup(models.Model):
related_name='rack_groups' related_name='rack_groups'
) )
csv_headers = ['site', 'name', 'slug']
serializer = 'dcim.api.serializers.RackGroupSerializer' serializer = 'dcim.api.serializers.RackGroupSerializer'
csv_headers = ['site', 'name', 'slug']
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
@ -287,7 +312,7 @@ class RackGroup(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class RackRole(models.Model): class RackRole(ChangeLoggedModel):
""" """
Racks can be organized by functional role, similar to Devices. Racks can be organized by functional role, similar to Devices.
""" """
@ -300,6 +325,7 @@ class RackRole(models.Model):
) )
color = ColorField() color = ColorField()
serializer = 'dcim.api.serializers.RackRoleSerializer'
csv_headers = ['name', 'slug', 'color'] csv_headers = ['name', 'slug', 'color']
class Meta: class Meta:
@ -324,7 +350,7 @@ class RackManager(NaturalOrderByManager):
@python_2_unicode_compatible @python_2_unicode_compatible
class Rack(CreatedUpdatedModel, CustomFieldModel): class Rack(ChangeLoggedModel, CustomFieldModel):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a RackGroup. Each Rack is assigned to a Site and (optionally) a RackGroup.
@ -406,13 +432,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
objects = RackManager() objects = RackManager()
tags = TaggableManager() tags = TaggableManager()
serializer = 'dcim.api.serializers.RackSerializer'
csv_headers = [ csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
'desc_units', 'comments', 'desc_units', 'comments',
] ]
serializer = 'dcim.api.serializers.RackSerializer'
class Meta: class Meta:
ordering = ['site', 'group', 'name'] ordering = ['site', 'group', 'name']
unique_together = [ unique_together = [
@ -584,7 +609,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class RackReservation(models.Model): class RackReservation(ChangeLoggedModel):
""" """
One or more reserved units within a Rack. One or more reserved units within a Rack.
""" """
@ -596,9 +621,6 @@ class RackReservation(models.Model):
units = ArrayField( units = ArrayField(
base_field=models.PositiveSmallIntegerField() base_field=models.PositiveSmallIntegerField()
) )
created = models.DateTimeField(
auto_now_add=True
)
tenant = models.ForeignKey( tenant = models.ForeignKey(
to='tenancy.Tenant', to='tenancy.Tenant',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -614,6 +636,8 @@ class RackReservation(models.Model):
max_length=100 max_length=100
) )
serializer = 'dcim.api.serializers.RackReservationSerializer'
class Meta: class Meta:
ordering = ['created'] ordering = ['created']
@ -661,7 +685,7 @@ class RackReservation(models.Model):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class Manufacturer(models.Model): class Manufacturer(ChangeLoggedModel):
""" """
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
""" """
@ -673,6 +697,7 @@ class Manufacturer(models.Model):
unique=True unique=True
) )
serializer = 'dcim.api.serializers.ManufacturerSerializer'
csv_headers = ['name', 'slug'] csv_headers = ['name', 'slug']
class Meta: class Meta:
@ -692,7 +717,7 @@ class Manufacturer(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class DeviceType(CreatedUpdatedModel, CustomFieldModel): class DeviceType(ChangeLoggedModel, CustomFieldModel):
""" """
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
well as high-level functional role(s). well as high-level functional role(s).
@ -767,6 +792,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
serializer = 'dcim.api.serializers.DeviceTypeSerializer'
csv_headers = [ csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
@ -866,7 +892,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class ConsolePortTemplate(models.Model): class ConsolePortTemplate(ComponentModel):
""" """
A template for a ConsolePort to be created for a new Device. A template for a ConsolePort to be created for a new Device.
""" """
@ -886,9 +912,12 @@ class ConsolePortTemplate(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible @python_2_unicode_compatible
class ConsoleServerPortTemplate(models.Model): class ConsoleServerPortTemplate(ComponentModel):
""" """
A template for a ConsoleServerPort to be created for a new Device. A template for a ConsoleServerPort to be created for a new Device.
""" """
@ -908,9 +937,12 @@ class ConsoleServerPortTemplate(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible @python_2_unicode_compatible
class PowerPortTemplate(models.Model): class PowerPortTemplate(ComponentModel):
""" """
A template for a PowerPort to be created for a new Device. A template for a PowerPort to be created for a new Device.
""" """
@ -930,9 +962,12 @@ class PowerPortTemplate(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible @python_2_unicode_compatible
class PowerOutletTemplate(models.Model): class PowerOutletTemplate(ComponentModel):
""" """
A template for a PowerOutlet to be created for a new Device. A template for a PowerOutlet to be created for a new Device.
""" """
@ -952,9 +987,12 @@ class PowerOutletTemplate(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible @python_2_unicode_compatible
class InterfaceTemplate(models.Model): class InterfaceTemplate(ComponentModel):
""" """
A template for a physical data interface on a new Device. A template for a physical data interface on a new Device.
""" """
@ -984,9 +1022,12 @@ class InterfaceTemplate(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible @python_2_unicode_compatible
class DeviceBayTemplate(models.Model): class DeviceBayTemplate(ComponentModel):
""" """
A template for a DeviceBay to be created for a new parent Device. A template for a DeviceBay to be created for a new parent Device.
""" """
@ -1006,13 +1047,16 @@ class DeviceBayTemplate(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_component_parent(self):
return self.device_type
# #
# Devices # Devices
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class DeviceRole(models.Model): class DeviceRole(ChangeLoggedModel):
""" """
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
@ -1032,6 +1076,7 @@ class DeviceRole(models.Model):
help_text='Virtual machines may be assigned to this role' help_text='Virtual machines may be assigned to this role'
) )
serializer = 'dcim.api.serializers.DeviceRoleSerializer'
csv_headers = ['name', 'slug', 'color', 'vm_role'] csv_headers = ['name', 'slug', 'color', 'vm_role']
class Meta: class Meta:
@ -1053,7 +1098,7 @@ class DeviceRole(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Platform(models.Model): class Platform(ChangeLoggedModel):
""" """
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
@ -1087,6 +1132,7 @@ class Platform(models.Model):
verbose_name='Legacy RPC client' verbose_name='Legacy RPC client'
) )
serializer = 'dcim.api.serializers.PlatformSerializer'
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
class Meta: class Meta:
@ -1112,7 +1158,7 @@ class DeviceManager(NaturalOrderByManager):
@python_2_unicode_compatible @python_2_unicode_compatible
class Device(CreatedUpdatedModel, CustomFieldModel): class Device(ChangeLoggedModel, CustomFieldModel):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@ -1252,13 +1298,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
objects = DeviceManager() objects = DeviceManager()
tags = TaggableManager() tags = TaggableManager()
serializer = 'dcim.api.serializers.DeviceSerializer'
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
] ]
serializer = 'dcim.api.serializers.DeviceSerializer'
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
unique_together = [ unique_together = [
@ -1501,7 +1546,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class ConsolePort(models.Model): class ConsolePort(ComponentModel):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
""" """
@ -1538,6 +1583,9 @@ class ConsolePort(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def to_csv(self): def to_csv(self):
return ( return (
self.cs_port.device.identifier if self.cs_port else None, self.cs_port.device.identifier if self.cs_port else None,
@ -1563,7 +1611,7 @@ class ConsoleServerPortManager(models.Manager):
@python_2_unicode_compatible @python_2_unicode_compatible
class ConsoleServerPort(models.Model): class ConsoleServerPort(ComponentModel):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
""" """
@ -1587,6 +1635,9 @@ class ConsoleServerPort(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def clean(self): def clean(self):
# Check that the parent device's DeviceType is a console server # Check that the parent device's DeviceType is a console server
@ -1604,7 +1655,7 @@ class ConsoleServerPort(models.Model):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class PowerPort(models.Model): class PowerPort(ComponentModel):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
""" """
@ -1640,6 +1691,9 @@ class PowerPort(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def to_csv(self): def to_csv(self):
return ( return (
self.power_outlet.device.identifier if self.power_outlet else None, self.power_outlet.device.identifier if self.power_outlet else None,
@ -1665,7 +1719,7 @@ class PowerOutletManager(models.Manager):
@python_2_unicode_compatible @python_2_unicode_compatible
class PowerOutlet(models.Model): class PowerOutlet(ComponentModel):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
@ -1689,6 +1743,9 @@ class PowerOutlet(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def clean(self): def clean(self):
# Check that the parent device's DeviceType is a PDU # Check that the parent device's DeviceType is a PDU
@ -1706,7 +1763,7 @@ class PowerOutlet(models.Model):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class Interface(models.Model): class Interface(ComponentModel):
""" """
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
Interface via the creation of an InterfaceConnection. Interface via the creation of an InterfaceConnection.
@ -1796,6 +1853,9 @@ class Interface(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.parent.get_absolute_url() return self.parent.get_absolute_url()
def get_component_parent(self):
return self.device or self.virtual_machine
def clean(self): def clean(self):
# Check that the parent device's DeviceType is a network device # Check that the parent device's DeviceType is a network device
@ -1866,6 +1926,23 @@ class Interface(models.Model):
return super(Interface, self).save(*args, **kwargs) return super(Interface, self).save(*args, **kwargs)
def log_change(self, user, request_id, action):
"""
Include the connected Interface (if any).
"""
ObjectChange(
user=user,
request_id=request_id,
changed_object=self,
related_object=self.get_component_parent(),
action=action,
object_data=serialize_object(self, extra={
'connected_interface': self.connected_interface.pk if self.connection else None,
'connection_status': self.connection.connection_status if self.connection else None,
})
).save()
# TODO: Replace `parent` with get_component_parent() (from ComponentModel)
@property @property
def parent(self): def parent(self):
return self.device or self.virtual_machine return self.device or self.virtual_machine
@ -1970,13 +2047,40 @@ class InterfaceConnection(models.Model):
self.get_connection_status_display(), self.get_connection_status_display(),
) )
def log_change(self, user, request_id, action):
"""
Create a new ObjectChange for each of the two affected Interfaces.
"""
interfaces = (
(self.interface_a, self.interface_b),
(self.interface_b, self.interface_a),
)
for interface, peer_interface in interfaces:
if action == OBJECTCHANGE_ACTION_DELETE:
connection_data = {
'connected_interface': None,
}
else:
connection_data = {
'connected_interface': peer_interface.pk,
'connection_status': self.connection_status
}
ObjectChange(
user=user,
request_id=request_id,
changed_object=interface,
related_object=interface.parent,
action=OBJECTCHANGE_ACTION_UPDATE,
object_data=serialize_object(interface, extra=connection_data)
).save()
# #
# Device bays # Device bays
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class DeviceBay(models.Model): class DeviceBay(ComponentModel):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
@ -2007,6 +2111,9 @@ class DeviceBay(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def clean(self): def clean(self):
# Validate that the parent Device can have DeviceBays # Validate that the parent Device can have DeviceBays
@ -2025,7 +2132,7 @@ class DeviceBay(models.Model):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class InventoryItem(models.Model): class InventoryItem(ComponentModel):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes. InventoryItems are used only for inventory purposes.
@ -2094,6 +2201,9 @@ class InventoryItem(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def to_csv(self): def to_csv(self):
return ( return (
self.device.name or '{' + self.device.pk + '}', self.device.name or '{' + self.device.pk + '}',
@ -2112,7 +2222,7 @@ class InventoryItem(models.Model):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class VirtualChassis(models.Model): class VirtualChassis(ChangeLoggedModel):
""" """
A collection of Devices which operate with a shared control plane (e.g. a switch stack). A collection of Devices which operate with a shared control plane (e.g. a switch stack).
""" """
@ -2126,6 +2236,8 @@ class VirtualChassis(models.Model):
blank=True blank=True
) )
serializer = 'dcim.api.serializers.VirtualChassisSerializer'
class Meta: class Meta:
ordering = ['master'] ordering = ['master']
verbose_name_plural = 'virtual chassis' verbose_name_plural = 'virtual chassis'

View File

@ -41,12 +41,18 @@ DEVICE_LINK = """
""" """
REGION_ACTIONS = """ REGION_ACTIONS = """
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_region %} {% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
RACKGROUP_ACTIONS = """ RACKGROUP_ACTIONS = """
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations"> <a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
<i class="fa fa-eye"></i> <i class="fa fa-eye"></i>
</a> </a>
@ -58,6 +64,9 @@ RACKGROUP_ACTIONS = """
""" """
RACKROLE_ACTIONS = """ RACKROLE_ACTIONS = """
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackrole %} {% if perms.dcim.change_rackrole %}
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
@ -76,20 +85,29 @@ RACK_DEVICE_COUNT = """
""" """
RACKRESERVATION_ACTIONS = """ RACKRESERVATION_ACTIONS = """
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackreservation %} {% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
DEVICEROLE_ACTIONS = """ MANUFACTURER_ACTIONS = """
{% if perms.dcim.change_devicerole %} <a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_manufacturer %}
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
MANUFACTURER_ACTIONS = """ DEVICEROLE_ACTIONS = """
{% if perms.dcim.change_manufacturer %} <a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -110,6 +128,9 @@ PLATFORM_VM_COUNT = """
""" """
PLATFORM_ACTIONS = """ PLATFORM_ACTIONS = """
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_platform %} {% if perms.dcim.change_platform %}
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
@ -143,6 +164,9 @@ UTILIZATION_GRAPH = """
""" """
VIRTUALCHASSIS_ACTIONS = """ VIRTUALCHASSIS_ACTIONS = """
<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_virtualchassis %} {% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}

View File

@ -2,11 +2,14 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from extras.views import ImageAttachmentEditView from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView from ipam.views import ServiceCreateView
from secrets.views import secret_add from secrets.views import secret_add
from . import views from . import views
from .models import Device, Rack, Site from .models import (
Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, Region, Site,
VirtualChassis,
)
app_name = 'dcim' app_name = 'dcim'
urlpatterns = [ urlpatterns = [
@ -17,6 +20,7 @@ urlpatterns = [
url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'), url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
url(r'^regions/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites # Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
@ -26,6 +30,7 @@ urlpatterns = [
url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'), url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
url(r'^sites/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups # Rack groups
@ -34,6 +39,7 @@ urlpatterns = [
url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
url(r'^rack-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles # Rack roles
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
@ -41,6 +47,7 @@ urlpatterns = [
url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
url(r'^rack-roles/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations # Rack reservations
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'), url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
@ -48,6 +55,7 @@ urlpatterns = [
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
# Racks # Racks
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
@ -59,6 +67,7 @@ urlpatterns = [
url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'), url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
@ -68,6 +77,7 @@ urlpatterns = [
url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
url(r'^manufacturers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types # Device types
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
@ -78,6 +88,7 @@ urlpatterns = [
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
url(r'^device-types/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
# Console port templates # Console port templates
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
@ -110,6 +121,7 @@ urlpatterns = [
url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
url(r'^device-roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms # Platforms
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
@ -117,6 +129,7 @@ urlpatterns = [
url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'), url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
url(r'^platforms/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices # Devices
url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
@ -128,6 +141,7 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'), url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
@ -221,6 +235,7 @@ urlpatterns = [
url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
url(r'^virtual-chassis/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),

View File

@ -18,7 +18,7 @@ from django.views.generic import View
from natsort import natsorted from natsort import natsorted
from circuits.models import Circuit from circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE, UserAction from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from ipam.models import Prefix, Service, VLAN from ipam.models import Prefix, Service, VLAN
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
@ -945,6 +945,7 @@ class DeviceInventoryView(View):
return render(request, 'dcim/device_inventory.html', { return render(request, 'dcim/device_inventory.html', {
'device': device, 'device': device,
'inventory_items': inventory_items, 'inventory_items': inventory_items,
'active_tab': 'inventory',
}) })
@ -957,6 +958,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_status.html', { return render(request, 'dcim/device_status.html', {
'device': device, 'device': device,
'active_tab': 'status',
}) })
@ -975,6 +977,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_lldp_neighbors.html', { return render(request, 'dcim/device_lldp_neighbors.html', {
'device': device, 'device': device,
'interfaces': interfaces, 'interfaces': interfaces,
'active_tab': 'lldp-neighbors',
}) })
@ -987,6 +990,7 @@ class DeviceConfigView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_config.html', { return render(request, 'dcim/device_config.html', {
'device': device, 'device': device,
'active_tab': 'config',
}) })
@ -1104,7 +1108,6 @@ class ConsolePortConnectView(PermissionRequiredMixin, View):
escape(consoleport.cs_port.name), escape(consoleport.cs_port.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk) return redirect('dcim:device', pk=consoleport.device.pk)
@ -1155,7 +1158,6 @@ class ConsolePortDisconnectView(PermissionRequiredMixin, View):
escape(cs_port.name), escape(cs_port.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk) return redirect('dcim:device', pk=consoleport.device.pk)
@ -1244,7 +1246,6 @@ class ConsoleServerPortConnectView(PermissionRequiredMixin, View):
escape(consoleserverport.name), escape(consoleserverport.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk) return redirect('dcim:device', pk=consoleserverport.device.pk)
@ -1296,7 +1297,6 @@ class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View):
escape(consoleserverport.name), escape(consoleserverport.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk) return redirect('dcim:device', pk=consoleserverport.device.pk)
@ -1390,7 +1390,6 @@ class PowerPortConnectView(PermissionRequiredMixin, View):
escape(powerport.power_outlet.name), escape(powerport.power_outlet.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk) return redirect('dcim:device', pk=powerport.device.pk)
@ -1441,7 +1440,6 @@ class PowerPortDisconnectView(PermissionRequiredMixin, View):
escape(power_outlet.name), escape(power_outlet.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk) return redirect('dcim:device', pk=powerport.device.pk)
@ -1529,7 +1527,6 @@ class PowerOutletConnectView(PermissionRequiredMixin, View):
escape(poweroutlet.name), escape(poweroutlet.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk) return redirect('dcim:device', pk=poweroutlet.device.pk)
@ -1580,7 +1577,6 @@ class PowerOutletDisconnectView(PermissionRequiredMixin, View):
escape(poweroutlet.name), escape(poweroutlet.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk) return redirect('dcim:device', pk=poweroutlet.device.pk)
@ -1910,7 +1906,6 @@ class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, Vie
escape(interfaceconnection.interface_b.name), escape(interfaceconnection.interface_b.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:
base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
@ -1961,7 +1956,6 @@ class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin,
escape(interfaceconnection.interface_b.name), escape(interfaceconnection.interface_b.name),
) )
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
return redirect(self.get_return_url(request, interfaceconnection)) return redirect(self.get_return_url(request, interfaceconnection))
@ -2241,7 +2235,6 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
membership_form.save() membership_form.save()
msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device)) msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, device, msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.get_full_path()) return redirect(request.get_full_path())
@ -2296,7 +2289,6 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis) msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
messages.success(request, msg) messages.success(request, msg)
UserAction.objects.log_edit(request.user, device, msg)
return redirect(self.get_return_url(request, device)) return redirect(self.get_return_url(request, device))

View File

@ -5,9 +5,9 @@ from django.contrib import admin
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from utilities.forms import LaxURLField from utilities.forms import LaxURLField
from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from .models import ( from .models import (
CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, Webhook,
Webhook
) )
@ -125,6 +125,49 @@ class TopologyMapAdmin(admin.ModelAdmin):
} }
#
# Change logging
#
@admin.register(ObjectChange)
class ObjectChangeAdmin(admin.ModelAdmin):
actions = None
fields = ['time', 'changed_object_type', 'display_object', 'action', 'display_user', 'request_id', 'object_data']
list_display = ['time', 'changed_object_type', 'display_object', 'display_action', 'display_user', 'request_id']
list_filter = ['time', 'action', 'user__username']
list_select_related = ['changed_object_type', 'user']
readonly_fields = fields
search_fields = ['user_name', 'object_repr', 'request_id']
def has_add_permission(self, request):
return False
def display_user(self, obj):
if obj.user is not None:
return obj.user
else:
return '{} (deleted)'.format(obj.user_name)
display_user.short_description = 'user'
def display_action(self, obj):
icon = {
OBJECTCHANGE_ACTION_CREATE: 'addlink',
OBJECTCHANGE_ACTION_UPDATE: 'changelink',
OBJECTCHANGE_ACTION_DELETE: 'deletelink',
}
return mark_safe('<span class="{}">{}</span>'.format(icon[obj.action], obj.get_action_display()))
display_action.short_description = 'action'
def display_object(self, obj):
if hasattr(obj.changed_object, 'get_absolute_url'):
return mark_safe('<a href="{}">{}</a>'.format(obj.changed_object.get_absolute_url(), obj.changed_object))
elif obj.changed_object is not None:
return obj.changed_object
else:
return '{} (deleted)'.format(obj.object_repr)
display_object.short_description = 'object'
# #
# User actions # User actions
# #

View File

@ -6,11 +6,12 @@ from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site from dcim.models import Device, Rack, Site
from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES from extras.models import ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction
from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
from users.api.serializers import NestedUserSerializer
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
from extras.constants import * from extras.constants import *
from users.api.serializers import NestedUserSerializer
from utilities.api import (
ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer,
)
# #
@ -155,6 +156,35 @@ class ReportDetailSerializer(ReportSerializer):
result = ReportResultSerializer() result = ReportResultSerializer()
#
# Change logging
#
class ObjectChangeSerializer(serializers.ModelSerializer):
user = NestedUserSerializer(read_only=True)
content_type = ContentTypeFieldSerializer(read_only=True)
changed_object = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ObjectChange
fields = [
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data',
]
def get_changed_object(self, obj):
"""
Serialize a nested representation of the changed object.
"""
if obj.changed_object is None:
return None
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
if serializer is None:
return obj.object_repr
context = {'request': self.context['request']}
data = serializer(obj.changed_object, context=context).data
return data
# #
# User actions # User actions
# #

View File

@ -37,6 +37,9 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet)
# Reports # Reports
router.register(r'reports', views.ReportViewSet, base_name='report') router.register(r'reports', views.ReportViewSet, base_name='report')
# Change logging
router.register(r'object-changes', views.ObjectChangeViewSet)
# Recent activity # Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet) router.register(r'recent-activity', views.RecentActivityViewSet)

View File

@ -11,7 +11,9 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from taggit.models import Tag from taggit.models import Tag
from extras import filters from extras import filters
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction from extras.models import (
CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction,
)
from extras.reports import get_report, get_reports from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
from . import serializers from . import serializers
@ -206,6 +208,19 @@ class ReportViewSet(ViewSet):
return Response(serializer.data) return Response(serializer.data)
#
# Change logging
#
class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
queryset = ObjectChange.objects.select_related('user')
serializer_class = serializers.ObjectChangeSerializer
filter_class = filters.ObjectChangeFilter
# #
# User activity # User activity
# #

View File

@ -66,6 +66,16 @@ TOPOLOGYMAP_TYPE_CHOICES = (
(TOPOLOGYMAP_TYPE_POWER, 'Power'), (TOPOLOGYMAP_TYPE_POWER, 'Power'),
) )
# Change log actions
OBJECTCHANGE_ACTION_CREATE = 1
OBJECTCHANGE_ACTION_UPDATE = 2
OBJECTCHANGE_ACTION_DELETE = 3
OBJECTCHANGE_ACTION_CHOICES = (
(OBJECTCHANGE_ACTION_CREATE, 'Created'),
(OBJECTCHANGE_ACTION_UPDATE, 'Updated'),
(OBJECTCHANGE_ACTION_DELETE, 'Deleted'),
)
# User action types # User action types
ACTION_CREATE = 1 ACTION_CREATE = 1
ACTION_IMPORT = 2 ACTION_IMPORT = 2

View File

@ -8,7 +8,7 @@ from taggit.models import Tag
from dcim.models import Site from dcim.models import Site
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
class CustomFieldFilter(django_filters.Filter): class CustomFieldFilter(django_filters.Filter):
@ -124,6 +124,26 @@ class TopologyMapFilter(django_filters.FilterSet):
fields = ['name', 'slug'] fields = ['name', 'slug']
class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
time = django_filters.DateTimeFromToRangeFilter()
class Meta:
model = ObjectChange
fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
)
class UserActionFilter(django_filters.FilterSet): class UserActionFilter(django_filters.FilterSet):
username = django_filters.ModelMultipleChoiceFilter( username = django_filters.ModelMultipleChoiceFilter(
name='user__username', name='user__username',

View File

@ -3,12 +3,16 @@ from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from django import forms from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from taggit.models import Tag from taggit.models import Tag
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField, SlugField from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, SlugField
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL from .constants import (
from .models import CustomField, CustomFieldValue, ImageAttachment CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES,
)
from .models import CustomField, CustomFieldValue, ImageAttachment, ObjectChange
# #
@ -189,3 +193,38 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ImageAttachment model = ImageAttachment
fields = ['name', 'image'] fields = ['name', 'image']
#
# Change logging
#
class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = ObjectChange
q = forms.CharField(
required=False,
label='Search'
)
# TODO: Change time_0 and time_1 to time_after and time_before for django-filter==2.0
time_0 = forms.DateTimeField(
label='After',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
)
time_1 = forms.DateTimeField(
label='Before',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
)
action = forms.ChoiceField(
choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES),
required=False
)
user = forms.ModelChoiceField(
queryset=User.objects.order_by('username'),
required=False
)

View File

@ -0,0 +1,65 @@
from __future__ import unicode_literals
from datetime import timedelta
import random
import uuid
from django.conf import settings
from django.db.models.signals import post_delete, post_save
from django.utils import timezone
from django.utils.functional import curry, SimpleLazyObject
from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from .models import ObjectChange
def record_object_change(user, request_id, instance, **kwargs):
"""
Create an ObjectChange in response to an object being created or deleted.
"""
if not hasattr(instance, 'log_change'):
return
# Determine what action is being performed. The post_save signal sends a `created` boolean, whereas post_delete
# does not.
if 'created' in kwargs:
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
else:
action = OBJECTCHANGE_ACTION_DELETE
instance.log_change(user, request_id, action)
# 1% chance of clearing out expired ObjectChanges
if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
purged_count, _ = ObjectChange.objects.filter(
time__lt=cutoff
).delete()
class ChangeLoggingMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
def get_user(request):
return request.user
# DRF employs a separate authentication mechanism outside Django's normal request/response cycle, so calling
# request.user in middleware will always return AnonymousUser for API requests. To work around this, we point
# to a lazy object that doesn't resolve the user until after DRF's authentication has been called. For more
# detail, see https://stackoverflow.com/questions/26240832/
user = SimpleLazyObject(lambda: get_user(request))
request_id = uuid.uuid4()
# Django doesn't provide any request context with the post_save/post_delete signals, so we curry
# record_object_change() to include the user associated with the current request.
_record_object_change = curry(record_object_change, user, request_id)
post_save.connect(_record_object_change, dispatch_uid='record_object_saved')
post_delete.connect(_record_object_change, dispatch_uid='record_object_deleted')
return self.get_response(request)

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-22 18:13
from __future__ import unicode_literals
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0012_webhooks'),
]
operations = [
migrations.CreateModel(
name='ObjectChange',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(auto_now_add=True)),
('user_name', models.CharField(editable=False, max_length=150)),
('request_id', models.UUIDField(editable=False)),
('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])),
('changed_object_id', models.PositiveIntegerField()),
('related_object_id', models.PositiveIntegerField(blank=True, null=True)),
('object_repr', models.CharField(editable=False, max_length=200)),
('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)),
('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-time'],
},
),
]

View File

@ -2,12 +2,14 @@ from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from datetime import date from datetime import date
import json
import graphviz import graphviz
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.urls import reverse
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
@ -656,6 +658,119 @@ class ReportResult(models.Model):
ordering = ['report'] ordering = ['report']
#
# Change logging
#
@python_2_unicode_compatible
class ObjectChange(models.Model):
"""
Record a change to an object and the user account associated with that change. A change record may optionally
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
parent device. This will ensure changes made to component models appear in the parent model's changelog.
"""
time = models.DateTimeField(
auto_now_add=True,
editable=False
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
related_name='changes',
blank=True,
null=True
)
user_name = models.CharField(
max_length=150,
editable=False
)
request_id = models.UUIDField(
editable=False
)
action = models.PositiveSmallIntegerField(
choices=OBJECTCHANGE_ACTION_CHOICES
)
changed_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
changed_object_id = models.PositiveIntegerField()
changed_object = GenericForeignKey(
ct_field='changed_object_type',
fk_field='changed_object_id'
)
related_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+',
blank=True,
null=True
)
related_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
related_object = GenericForeignKey(
ct_field='related_object_type',
fk_field='related_object_id'
)
object_repr = models.CharField(
max_length=200,
editable=False
)
object_data = JSONField(
editable=False
)
serializer = 'extras.api.serializers.ObjectChangeSerializer'
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
]
class Meta:
ordering = ['-time']
def __str__(self):
return '{} {} {} by {}'.format(
self.changed_object_type,
self.object_repr,
self.get_action_display().lower(),
self.user_name
)
def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings
self.user_name = self.user.username
self.object_repr = str(self.changed_object)
return super(ObjectChange, self).save(*args, **kwargs)
def get_absolute_url(self):
return reverse('extras:objectchange', args=[self.pk])
def to_csv(self):
return (
self.time,
self.user,
self.user_name,
self.request_id,
self.get_action_display(),
self.changed_object_type,
self.changed_object_id,
self.related_object_type,
self.related_object_id,
self.object_repr,
self.object_data,
)
@property
def object_data_pretty(self):
return json.dumps(self.object_data, indent=4, sort_keys=True)
# #
# User actions # User actions
# #

View File

@ -4,6 +4,7 @@ import django_tables2 as tables
from taggit.models import Tag from taggit.models import Tag
from utilities.tables import BaseTable, ToggleColumn from utilities.tables import BaseTable, ToggleColumn
from .models import ObjectChange
TAG_ACTIONS = """ TAG_ACTIONS = """
{% if perms.taggit.change_tag %} {% if perms.taggit.change_tag %}
@ -14,6 +15,24 @@ TAG_ACTIONS = """
{% endif %} {% endif %}
""" """
OBJECTCHANGE_ACTION = """
{% if record.action == 1 %}
<span class="label label-success">Created</span>
{% elif record.action == 2 %}
<span class="label label-primary">Updated</span>
{% elif record.action == 3 %}
<span class="label label-danger">Deleted</span>
{% endif %}
"""
OBJECTCHANGE_OBJECT = """
{% if record.action != 3 and record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}
"""
class TagTable(BaseTable): class TagTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
@ -26,3 +45,24 @@ class TagTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Tag model = Tag
fields = ('pk', 'name', 'items') fields = ('pk', 'name', 'items')
class ObjectChangeTable(BaseTable):
time = tables.LinkColumn()
action = tables.TemplateColumn(
template_code=OBJECTCHANGE_ACTION
)
changed_object_type = tables.Column(
verbose_name='Type'
)
object_repr = tables.TemplateColumn(
template_code=OBJECTCHANGE_OBJECT,
verbose_name='Object'
)
request_id = tables.Column(
verbose_name='Request ID'
)
class Meta(BaseTable.Meta):
model = ObjectChange
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')

View File

@ -22,4 +22,8 @@ urlpatterns = [
url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
# Change logging
url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'),
url(r'^changelog/(?P<pk>\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'),
] ]

View File

@ -1,8 +1,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django import template
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render, reverse from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -11,10 +13,11 @@ from taggit.models import Tag
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from .forms import ImageAttachmentForm, TagForm from . import filters
from .models import ImageAttachment, ReportResult, UserAction from .forms import ObjectChangeFilterForm, ImageAttachmentForm, TagForm
from .models import ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .tables import TagTable from .tables import ObjectChangeTable, TagTable
# #
@ -50,6 +53,77 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
#
# Change logging
#
class ObjectChangeListView(ObjectListView):
queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
filter = filters.ObjectChangeFilter
filter_form = ObjectChangeFilterForm
table = ObjectChangeTable
template_name = 'extras/objectchange_list.html'
class ObjectChangeView(View):
def get(self, request, pk):
objectchange = get_object_or_404(ObjectChange, pk=pk)
related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk)
related_changes_table = ObjectChangeTable(
data=related_changes[:50],
orderable=False
)
return render(request, 'extras/objectchange.html', {
'objectchange': objectchange,
'related_changes_table': related_changes_table,
'related_changes_count': related_changes.count()
})
class ObjectChangeLogView(View):
"""
Present a history of changes made to a particular object.
"""
def get(self, request, model, **kwargs):
# Get object my model and kwargs (e.g. slug='foo')
obj = get_object_or_404(model, **kwargs)
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
objectchanges = ObjectChange.objects.select_related(
'user', 'changed_object_type'
).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
Q(related_object_type=content_type, related_object_id=obj.pk)
)
objectchanges_table = ObjectChangeTable(
data=objectchanges,
orderable=False
)
# Check whether a header template exists for this model
base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name)
try:
template.loader.get_template(base_template)
object_var = model._meta.model_name
except template.TemplateDoesNotExist:
base_template = '_base.html'
object_var = 'obj'
return render(request, 'extras/object_changelog.html', {
object_var: obj,
'objectchanges_table': objectchanges_table,
'base_template': base_template,
'active_tab': 'changelog',
})
# #
# Image attachments # Image attachments
# #
@ -149,6 +223,5 @@ class ReportRunView(PermissionRequiredMixin, View):
result = 'failed' if report.failed else 'passed' result = 'failed' if report.failed else 'passed'
msg = "Ran report {} ({})".format(report.full_name, result) msg = "Ran report {} ({})".format(report.full_name, result)
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
UserAction.objects.log_create(request.user, report.result, msg)
return redirect('extras:report', name=report.full_name) return redirect('extras:report', name=report.full_name)

View File

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0022_tags'),
]
operations = [
migrations.AddField(
model_name='rir',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='rir',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='role',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='role',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='vlangroup',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='vlangroup',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='aggregate',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='aggregate',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='ipaddress',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='ipaddress',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='prefix',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='prefix',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='service',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='service',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='vlan',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='vlan',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='vrf',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='vrf',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -14,14 +14,14 @@ from taggit.managers import TaggableManager
from dcim.models import Interface from dcim.models import Interface
from extras.models import CustomFieldModel from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel from utilities.models import ChangeLoggedModel
from .constants import * from .constants import *
from .fields import IPNetworkField, IPAddressField from .fields import IPNetworkField, IPAddressField
from .querysets import PrefixQuerySet from .querysets import PrefixQuerySet
@python_2_unicode_compatible @python_2_unicode_compatible
class VRF(CreatedUpdatedModel, CustomFieldModel): class VRF(ChangeLoggedModel, CustomFieldModel):
""" """
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
@ -59,9 +59,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
serializer = 'ipam.api.serializers.VRFSerializer' serializer = 'ipam.api.serializers.VRFSerializer'
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta: class Meta:
ordering = ['name', 'rd'] ordering = ['name', 'rd']
@ -91,7 +90,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class RIR(models.Model): class RIR(ChangeLoggedModel):
""" """
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918.
@ -109,6 +108,7 @@ class RIR(models.Model):
help_text='IP space managed by this RIR is considered private' help_text='IP space managed by this RIR is considered private'
) )
serializer = 'ipam.api.serializers.RIRSerializer'
csv_headers = ['name', 'slug', 'is_private'] csv_headers = ['name', 'slug', 'is_private']
class Meta: class Meta:
@ -131,7 +131,7 @@ class RIR(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Aggregate(CreatedUpdatedModel, CustomFieldModel): class Aggregate(ChangeLoggedModel, CustomFieldModel):
""" """
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@ -162,9 +162,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['prefix', 'rir', 'date_added', 'description']
serializer = 'ipam.api.serializers.AggregateSerializer' serializer = 'ipam.api.serializers.AggregateSerializer'
csv_headers = ['prefix', 'rir', 'date_added', 'description']
class Meta: class Meta:
ordering = ['family', 'prefix'] ordering = ['family', 'prefix']
@ -228,7 +227,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class Role(models.Model): class Role(ChangeLoggedModel):
""" """
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
"Management." "Management."
@ -244,6 +243,7 @@ class Role(models.Model):
default=1000 default=1000
) )
serializer = 'ipam.api.serializers.RoleSerializer'
csv_headers = ['name', 'slug', 'weight'] csv_headers = ['name', 'slug', 'weight']
class Meta: class Meta:
@ -261,7 +261,7 @@ class Role(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel): class Prefix(ChangeLoggedModel, CustomFieldModel):
""" """
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@ -336,12 +336,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
objects = PrefixQuerySet.as_manager() objects = PrefixQuerySet.as_manager()
tags = TaggableManager() tags = TaggableManager()
serializer = 'ipam.api.serializers.PrefixSerializer'
csv_headers = [ csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
] ]
serializer = 'ipam.api.serializers.PrefixSerializer'
class Meta: class Meta:
ordering = ['vrf', 'family', 'prefix'] ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes' verbose_name_plural = 'prefixes'
@ -503,7 +502,7 @@ class IPAddressManager(models.Manager):
@python_2_unicode_compatible @python_2_unicode_compatible
class IPAddress(CreatedUpdatedModel, CustomFieldModel): class IPAddress(ChangeLoggedModel, CustomFieldModel):
""" """
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
@ -578,13 +577,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
objects = IPAddressManager() objects = IPAddressManager()
tags = TaggableManager() tags = TaggableManager()
serializer = 'ipam.api.serializers.IPAddressSerializer'
csv_headers = [ csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
'description', 'description',
] ]
serializer = 'ipam.api.serializers.IPAddressSerializer'
class Meta: class Meta:
ordering = ['family', 'address'] ordering = ['family', 'address']
verbose_name = 'IP address' verbose_name = 'IP address'
@ -663,7 +661,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class VLANGroup(models.Model): class VLANGroup(ChangeLoggedModel):
""" """
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
""" """
@ -679,9 +677,8 @@ class VLANGroup(models.Model):
null=True null=True
) )
csv_headers = ['name', 'slug', 'site']
serializer = 'ipam.api.serializers.VLANGroupSerializer' serializer = 'ipam.api.serializers.VLANGroupSerializer'
csv_headers = ['name', 'slug', 'site']
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
@ -717,7 +714,7 @@ class VLANGroup(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel): class VLAN(ChangeLoggedModel, CustomFieldModel):
""" """
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
@ -778,9 +775,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
serializer = 'ipam.api.serializers.VLANSerializer' serializer = 'ipam.api.serializers.VLANSerializer'
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
class Meta: class Meta:
ordering = ['site', 'group', 'vid'] ordering = ['site', 'group', 'vid']
@ -835,7 +831,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class Service(CreatedUpdatedModel, CustomFieldModel): 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.

View File

@ -28,6 +28,9 @@ RIR_UTILIZATION = """
""" """
RIR_ACTIONS = """ RIR_ACTIONS = """
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_rir %} {% if perms.ipam.change_rir %}
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
@ -47,6 +50,9 @@ ROLE_VLAN_COUNT = """
""" """
ROLE_ACTIONS = """ ROLE_ACTIONS = """
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_role %} {% if perms.ipam.change_role %}
<a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
@ -127,6 +133,9 @@ VLAN_ROLE_LINK = """
""" """
VLANGROUP_ACTIONS = """ VLANGROUP_ACTIONS = """
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% with next_vid=record.get_next_available_vid %} {% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %} {% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success"> <a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views from . import views
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
app_name = 'ipam' app_name = 'ipam'
@ -17,6 +19,7 @@ urlpatterns = [
url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'), url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
url(r'^vrfs/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
# RIRs # RIRs
url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
@ -24,6 +27,7 @@ urlpatterns = [
url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'), url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'),
url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
url(r'^vrfs/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
# Aggregates # Aggregates
url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
@ -34,6 +38,7 @@ urlpatterns = [
url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'), url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
url(r'^aggregates/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
# Roles # Roles
url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
@ -41,6 +46,7 @@ urlpatterns = [
url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'), url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'),
url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
url(r'^roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
# Prefixes # Prefixes
url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
@ -51,6 +57,7 @@ urlpatterns = [
url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'), url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
url(r'^prefixes/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
@ -61,6 +68,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/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), 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'),
@ -72,6 +80,7 @@ urlpatterns = [
url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
url(r'^vlan-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
# VLANs # VLANs
url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
@ -83,6 +92,7 @@ urlpatterns = [
url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'), url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
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/$', views.ServiceListView.as_view(), name='service_list'),
@ -91,5 +101,6 @@ urlpatterns = [
url(r'^services/(?P<pk>\d+)/$', views.ServiceView.as_view(), name='service'), 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}),
] ]

View File

@ -522,6 +522,7 @@ class PrefixPrefixesView(View):
'prefix_table': prefix_table, 'prefix_table': prefix_table,
'permissions': permissions, 'permissions': permissions,
'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), 'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'prefixes',
}) })
@ -560,6 +561,7 @@ class PrefixIPAddressesView(View):
'ip_table': ip_table, 'ip_table': ip_table,
'permissions': permissions, 'permissions': permissions,
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'ip-addresses',
}) })
@ -859,8 +861,6 @@ class VLANMembersView(View):
members = vlan.get_members().select_related('device', 'virtual_machine') members = vlan.get_members().select_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members) members_table = tables.VLANMemberTable(members)
# if request.user.has_perm('dcim.change_interface'):
# members_table.columns.show('pk')
paginate = { paginate = {
'klass': EnhancedPaginator, 'klass': EnhancedPaginator,
@ -868,18 +868,10 @@ class VLANMembersView(View):
} }
RequestConfig(request, paginate).configure(members_table) RequestConfig(request, paginate).configure(members_table)
# Compile permissions list for rendering the object table
# permissions = {
# 'add': request.user.has_perm('ipam.add_ipaddress'),
# 'change': request.user.has_perm('ipam.change_ipaddress'),
# 'delete': request.user.has_perm('ipam.delete_ipaddress'),
# }
return render(request, 'ipam/vlan_members.html', { return render(request, 'ipam/vlan_members.html', {
'vlan': vlan, 'vlan': vlan,
'members_table': members_table, 'members_table': members_table,
# 'permissions': permissions, 'active_tab': 'members',
# 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
}) })

View File

@ -50,6 +50,9 @@ BANNER_LOGIN = ''
# BASE_PATH = 'netbox/' # BASE_PATH = 'netbox/'
BASE_PATH = '' BASE_PATH = ''
# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
CHANGELOG_RETENTION = 90
# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers

View File

@ -44,6 +44,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
BASE_PATH = getattr(configuration, 'BASE_PATH', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH: if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
@ -174,6 +175,7 @@ MIDDLEWARE = (
'utilities.middleware.ExceptionHandlingMiddleware', 'utilities.middleware.ExceptionHandlingMiddleware',
'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.LoginRequiredMiddleware',
'utilities.middleware.APIVersionMiddleware', 'utilities.middleware.APIVersionMiddleware',
'extras.middleware.ChangeLoggingMiddleware',
) )
ROOT_URLCONF = 'netbox.urls' ROOT_URLCONF = 'netbox.urls'

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:29
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('secrets', '0004_tags'),
]
operations = [
migrations.AddField(
model_name='secretrole',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='secretrole',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='secret',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='secret',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -14,7 +14,7 @@ from django.urls import reverse
from django.utils.encoding import force_bytes, python_2_unicode_compatible from django.utils.encoding import force_bytes, python_2_unicode_compatible
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from utilities.models import CreatedUpdatedModel from utilities.models import ChangeLoggedModel
from .exceptions import InvalidKey from .exceptions import InvalidKey
from .hashers import SecretValidationHasher from .hashers import SecretValidationHasher
from .querysets import UserKeyQuerySet from .querysets import UserKeyQuerySet
@ -48,12 +48,18 @@ def decrypt_master_key(master_key_cipher, private_key):
@python_2_unicode_compatible @python_2_unicode_compatible
class UserKey(CreatedUpdatedModel): class UserKey(models.Model):
""" """
A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
matching (private) decryption key. matching (private) decryption key.
""" """
created = models.DateField(
auto_now_add=True
)
last_updated = models.DateTimeField(
auto_now=True
)
user = models.OneToOneField( user = models.OneToOneField(
to=User, to=User,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -251,7 +257,7 @@ class SessionKey(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class SecretRole(models.Model): class SecretRole(ChangeLoggedModel):
""" """
A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
such as "Login Credentials" or "SNMP Communities." such as "Login Credentials" or "SNMP Communities."
@ -277,6 +283,7 @@ class SecretRole(models.Model):
blank=True blank=True
) )
serializer = 'ipam.api.secrets.SecretSerializer'
csv_headers = ['name', 'slug'] csv_headers = ['name', 'slug']
class Meta: class Meta:
@ -304,7 +311,7 @@ class SecretRole(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Secret(CreatedUpdatedModel): class Secret(ChangeLoggedModel):
""" """
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a
@ -340,6 +347,7 @@ class Secret(CreatedUpdatedModel):
tags = TaggableManager() tags = TaggableManager()
plaintext = None plaintext = None
serializer = 'ipam.api.secrets.SecretSerializer'
csv_headers = ['device', 'role', 'name', 'plaintext'] csv_headers = ['device', 'role', 'name', 'plaintext']
class Meta: class Meta:

View File

@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import SecretRole, Secret from .models import SecretRole, Secret
SECRETROLE_ACTIONS = """ SECRETROLE_ACTIONS = """
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.secrets.change_secretrole %} {% if perms.secrets.change_secretrole %}
<a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views from . import views
from .models import Secret, SecretRole
app_name = 'secrets' app_name = 'secrets'
urlpatterns = [ urlpatterns = [
@ -13,6 +15,7 @@ urlpatterns = [
url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
url(r'^secret-roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
# Secrets # Secrets
url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
@ -22,5 +25,6 @@ urlpatterns = [
url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'), url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'), url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'), url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'),
url(r'^secrets/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
] ]

View File

@ -34,6 +34,7 @@
{{ message }} {{ message }}
</div> </div>
{% endfor %} {% endfor %}
{% block header %}{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
<div class="push"></div> <div class="push"></div>
{% if settings.BANNER_BOTTOM %} {% if settings.BANNER_BOTTOM %}

View File

@ -1,44 +1,57 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block title %}{{ circuit }}{% endblock %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
<li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
<li>{{ circuit.cid }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:circuit_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<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">
{% if perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this circuit
</a>
{% endif %}
{% if perms.circuits.delete_circuit %}
<a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this circuit
</a>
{% endif %}
</div>
<h1>{{ circuit }}</h1>
{% include 'inc/created_updated.html' with obj=circuit %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ circuit.get_absolute_url }}">Circuit</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
<li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
<li>{{ circuit.cid }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:circuit_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<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">
{% if perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this circuit
</a>
{% endif %}
{% if perms.circuits.delete_circuit %}
<a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this circuit
</a>
{% endif %}
</div>
<h1>{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=circuit %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -2,49 +2,62 @@
{% load static from staticfiles %} {% load static from staticfiles %}
{% load helpers %} {% load helpers %}
{% block title %}{{ provider }}{% endblock %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
<li>{{ provider }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:provider_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<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">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.circuits.change_provider %}
<a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this provider
</a>
{% endif %}
{% if perms.circuits.delete_provider %}
<a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this provider
</a>
{% endif %}
</div>
<h1>{{ provider }}</h1>
{% include 'inc/created_updated.html' with obj=provider %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ provider.get_absolute_url }}">Provider</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
<li>{{ provider }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:provider_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<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">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.circuits.change_provider %}
<a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this provider
</a>
{% endif %}
{% if perms.circuits.delete_provider %}
<a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this provider
</a>
{% endif %}
</div>
<h1>{% block title %}{{ provider }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=provider %}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="panel panel-default"> <div class="panel panel-default">

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,10 @@
{% extends '_base.html' %} {% extends 'dcim/device.html' %}
{% load staticfiles %} {% load staticfiles %}
{% block title %}{{ device }} - Config{% endblock %} {% block title %}{{ device }} - Config{% endblock %}
{% block content %} {% block content %}
{% include 'inc/ajax_loader.html' %} {% include 'inc/ajax_loader.html' %}
{% include 'dcim/inc/device_header.html' with active_tab='config' %}
<div class="row"> <div class="row">
<div class="col-md-10 col-md-offset-1"> <div class="col-md-10 col-md-offset-1">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,77 +1,76 @@
{% extends '_base.html' %} {% extends 'dcim/device.html' %}
{% block title %}{{ device }} - Inventory{% endblock %} {% block title %}{{ device }} - Inventory{% endblock %}
{% block content %} {% block content %}
{% include 'dcim/inc/device_header.html' with active_tab='inventory' %} <div class="row">
<div class="row"> <div class="col-md-4">
<div class="col-md-4"> <div class="panel panel-default">
<div class="panel panel-default"> <div class="panel-heading">
<div class="panel-heading"> <strong>Chassis</strong>
<strong>Chassis</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Model</td>
<td>{{ device.device_type.full_name }}</td>
</tr>
<tr>
<td>Serial Number</td>
<td>
{% if device.serial %}
<span>{{ device.serial }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Asset Tag</td>
<td>
{% if device.asset_tag %}
<span>{{ device.asset_tag }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Hardware</strong>
</div>
<table class="table table-hover table-condensed panel-body" id="hardware">
<thead>
<tr>
<th>Name</th>
<th></th>
<th>Manufacturer</th>
<th>Part Number</th>
<th>Serial Number</th>
<th>Asset Tag</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in inventory_items %}
{% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
{% include template_name %}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if perms.dcim.add_inventoryitem %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
</a>
</div> </div>
{% endif %} <table class="table table-hover panel-body attr-table">
<tr>
<td>Model</td>
<td>{{ device.device_type.full_name }}</td>
</tr>
<tr>
<td>Serial Number</td>
<td>
{% if device.serial %}
<span>{{ device.serial }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Asset Tag</td>
<td>
{% if device.asset_tag %}
<span>{{ device.asset_tag }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Hardware</strong>
</div>
<table class="table table-hover table-condensed panel-body" id="hardware">
<thead>
<tr>
<th>Name</th>
<th></th>
<th>Manufacturer</th>
<th>Part Number</th>
<th>Serial Number</th>
<th>Asset Tag</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in inventory_items %}
{% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
{% include template_name %}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if perms.dcim.add_inventoryitem %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
</a>
</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,10 +1,9 @@
{% extends '_base.html' %} {% extends 'dcim/device.html' %}
{% block title %}{{ device }} - LLDP Neighbors{% endblock %} {% block title %}{{ device }} - LLDP Neighbors{% endblock %}
{% block content %} {% block content %}
{% include 'inc/ajax_loader.html' %} {% include 'inc/ajax_loader.html' %}
{% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>LLDP Neighbors</strong> <strong>LLDP Neighbors</strong>

View File

@ -1,11 +1,10 @@
{% extends '_base.html' %} {% extends 'dcim/device.html' %}
{% load staticfiles %} {% load staticfiles %}
{% block title %}{{ device }} - Status{% endblock %} {% block title %}{{ device }} - Status{% endblock %}
{% block content %} {% block content %}
{% include 'inc/ajax_loader.html' %} {% include 'inc/ajax_loader.html' %}
{% include 'dcim/inc/device_header.html' with active_tab='status' %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,34 +1,47 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}
{% block header %}
<div class="row">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:devicetype_list' %}">Device Types</a></li>
<li><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></li>
<li>{{ devicetype.model }}</li>
</ol>
</div>
</div>
{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
<div class="pull-right">
{% if perms.dcim.change_devicetype %}
<a href="{% url 'dcim:devicetype_edit' pk=devicetype.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this device type
</a>
{% endif %}
{% if perms.dcim.delete_devicetype %}
<a href="{% url 'dcim:devicetype_delete' pk=devicetype.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this device type
</a>
{% endif %}
</div>
{% endif %}
<h1>{{ devicetype.manufacturer }} {{ devicetype.model }}</h1>
{% include 'inc/created_updated.html' with obj=devicetype %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ devicetype.get_absolute_url }}">Device Type</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:devicetype_list' %}">Device Types</a></li>
<li><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></li>
<li>{{ devicetype.model }}</li>
</ol>
</div>
</div>
{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
<div class="pull-right">
{% if perms.dcim.change_devicetype %}
<a href="{% url 'dcim:devicetype_edit' pk=devicetype.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this device type
</a>
{% endif %}
{% if perms.dcim.delete_devicetype %}
<a href="{% url 'dcim:devicetype_delete' pk=devicetype.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this device type
</a>
{% endif %}
</div>
{% endif %}
<h1>{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=devicetype %}
<div class="row"> <div class="row">
<div class="col-md-5"> <div class="col-md-5">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,65 +0,0 @@
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
{% if device.rack %}
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
{% endif %}
{% if device.parent_bay %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
<li>{{ device.parent_bay }}</li>
{% endif %}
<li>{{ device }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:device_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search devices" />
<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">
{% if perms.dcim.change_device %}
<a href="{% url 'dcim:device_edit' pk=device.pk %}" class="btn btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit this device
</a>
{% endif %}
{% if perms.dcim.delete_device %}
<a href="{% url 'dcim:device_delete' pk=device.pk %}" class="btn btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Delete this device
</a>
{% endif %}
</div>
<h1>{{ device }}</h1>
{% include 'inc/created_updated.html' with obj=device %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}>
<a href="{% url 'dcim:device' pk=device.pk %}">Info</a>
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
</li>
{% if perms.dcim.napalm_read %}
{% if device.status != 1 %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
{% elif not device.platform %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
{% elif not device.platform.napalm_driver %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
{% elif not device.primary_ip %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
{% else %}
{% include 'dcim/inc/device_napalm_tabs.html' %}
{% endif %}
{% endif %}
</ul>

View File

@ -1,48 +1,59 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
<li>{{ rack }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:rack_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search racks" />
<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">
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
<span class="fa fa-chevron-left" aria-hidden="true"></span> Previous Rack
</a>
<a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
<span class="fa fa-chevron-right" aria-hidden="true"></span> Next Rack
</a>
{% if perms.dcim.change_rack %}
<a href="{% url 'dcim:rack_edit' pk=rack.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this rack
</a>
{% endif %}
{% if perms.dcim.delete_rack %}
<a href="{% url 'dcim:rack_delete' pk=rack.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete this rack
</a>
{% endif %}
</div>
<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=rack %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ rack.get_absolute_url }}">Rack</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
<li>{{ rack }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:rack_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search racks" />
<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">
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
<span class="fa fa-chevron-left" aria-hidden="true"></span> Previous Rack
</a>
<a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
<span class="fa fa-chevron-right" aria-hidden="true"></span> Next Rack
</a>
{% if perms.dcim.change_rack %}
<a href="{% url 'dcim:rack_edit' pk=rack.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this rack
</a>
{% endif %}
{% if perms.dcim.delete_rack %}
<a href="{% url 'dcim:rack_delete' pk=rack.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete this rack
</a>
{% endif %}
</div>
<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=rack %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -3,54 +3,66 @@
{% load tz %} {% load tz %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
{% if site.region %}
{% for region in site.region.get_ancestors %}
<li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
{% endfor %}
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
{% endif %}
<li>{{ site }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:site_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search sites" />
<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">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site-graphs' pk=site.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.dcim.change_site %}
<a href="{% url 'dcim:site_edit' slug=site.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this site
</a>
{% endif %}
{% if perms.dcim.delete_site %}
<a href="{% url 'dcim:site_delete' slug=site.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this site
</a>
{% endif %}
</div>
<h1>{% block title %}{{ site }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=site %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ site.get_absolute_url }}">Site</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
{% if site.region %}
{% for region in site.region.get_ancestors %}
<li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
{% endfor %}
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
{% endif %}
<li>{{ site }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:site_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search sites" />
<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">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site-graphs' pk=site.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.dcim.change_site %}
<a href="{% url 'dcim:site_edit' slug=site.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this site
</a>
{% endif %}
{% if perms.dcim.delete_site %}
<a href="{% url 'dcim:site_delete' slug=site.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this site
</a>
{% endif %}
</div>
<h1>{% block title %}{{ site }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=site %}
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -0,0 +1,8 @@
{% extends base_template %}
{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %}
{% block content %}
{% if obj %}<h1>{{ obj }}</h1>{% endif %}
{% include 'panel_table.html' with table=objectchanges_table %}
{% endblock %}

View File

@ -0,0 +1,101 @@
{% extends '_base.html' %}
{% load helpers %}
{% block title %}{{ objectchange }}{% endblock %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'extras:objectchange_list' %}">Changelog</a></li>
<li>{{ objectchange }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'extras:objectchange_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<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>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-5">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Change</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Time</td>
<td>
{{ objectchange.time }}
</td>
</tr>
<tr>
<td>User</td>
<td>
{{ objectchange.user|default:objectchange.user_name }}
</td>
</tr>
<tr>
<td>Action</td>
<td>
{{ objectchange.get_action_display }}
</td>
</tr>
<tr>
<td>Object Type</td>
<td>
{{ objectchange.changed_object_type }}
</td>
</tr>
<tr>
<td>Object</td>
<td>
{% if objectchange.changed_object.get_absolute_url %}
<a href="{{ objectchange.changed_object.get_absolute_url }}">{{ objectchange.changed_object }}</a>
{% else %}
{{ objectchange.object_repr }}
{% endif %}
</td>
</tr>
<tr>
<td>Request ID</td>
<td>
{{ objectchange.request_id }}
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-7">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Object Data</strong>
</div>
<div class="panel-body">
<pre>{{ objectchange.object_data_pretty }}</pre>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=related_changes_table heading='Related Changes' %}
{% if related_changes_count > related_changes_table.rows|length %}
<div class="pull-right">
<a href="{% url 'extras:objectchange_list' %}?request_id={{ objectchange.request_id }}" class="btn btn-primary">See all {{ related_changes_count|add:"1" }} changes</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right">
{% export_button content_type %}
</div>
<h1>{% block title %}Changelog{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -66,6 +66,9 @@
<li> <li>
<a href="{% url 'extras:report_list' %}">Reports</a> <a href="{% url 'extras:report_list' %}">Reports</a>
</li> </li>
<li>
<a href="{% url 'extras:objectchange_list' %}">Changelog</a>
</li>
</ul> </ul>
</li> </li>
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}"> <li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">

View File

@ -1,44 +1,55 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></li>
<li><a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a></li>
<li>{{ aggregate }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:aggregate_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search aggregates" />
<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">
{% if perms.ipam.change_aggregate %}
<a href="{% url 'ipam:aggregate_edit' pk=aggregate.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this aggregate
</a>
{% endif %}
{% if perms.ipam.delete_aggregate %}
<a href="{% url 'ipam:aggregate_delete' pk=aggregate.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this aggregate
</a>
{% endif %}
</div>
<h1>{% block title %}{{ aggregate }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=aggregate %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ aggregate.get_absolute_url }}">Aggregate</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></li>
<li><a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a></li>
<li>{{ aggregate }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:aggregate_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search aggregates" />
<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">
{% if perms.ipam.change_aggregate %}
<a href="{% url 'ipam:aggregate_edit' pk=aggregate.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this aggregate
</a>
{% endif %}
{% if perms.ipam.delete_aggregate %}
<a href="{% url 'ipam:aggregate_delete' pk=aggregate.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this aggregate
</a>
{% endif %}
</div>
<h1>{% block title %}{{ aggregate }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=aggregate %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,55 +0,0 @@
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
{% if prefix.vrf %}
<li><a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a></li>
{% endif %}
<li>{{ prefix }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:prefix_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search prefixes" />
<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">
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ prefix.vrf.pk }}&site={{ prefix.site.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
<i class="fa fa-plus" aria-hidden="true"></i> Add Child Prefix
</a>
{% endif %}
{% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ prefix.vrf.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
<span class="fa fa-plus" aria-hidden="true"></span>
Add an IP Address
</a>
{% endif %}
{% if perms.ipam.change_prefix %}
<a href="{% url 'ipam:prefix_edit' pk=prefix.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this prefix
</a>
{% endif %}
{% if perms.ipam.delete_prefix %}
<a href="{% url 'ipam:prefix_delete' pk=prefix.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this prefix
</a>
{% endif %}
</div>
<h1>{{ prefix }}</h1>
{% include 'inc/created_updated.html' with obj=prefix %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'prefix' %} class="active"{% endif %}><a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a></li>
<li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a></li>
<li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a></li>
</ul>

View File

@ -14,6 +14,9 @@
</td> </td>
<td>{{ service.description }}</td> <td>{{ service.description }}</td>
<td class="text-right"> <td class="text-right">
<a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_service %} {% if perms.ipam.change_service %}
<a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service"> <a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
<i class="glyphicon glyphicon-pencil"></i> <i class="glyphicon glyphicon-pencil"></i>

View File

@ -1,46 +0,0 @@
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
{% if vlan.site %}
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
{% endif %}
{% if vlan.group %}
<li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
{% endif %}
<li>{{ vlan }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:vlan_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search VLANs" />
<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">
{% if perms.ipam.change_vlan %}
<a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this VLAN
</a>
{% endif %}
{% if perms.ipam.delete_vlan %}
<a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this VLAN
</a>
{% endif %}
</div>
<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vlan %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'vlan' %} class="active"{% endif %}><a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a></li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}><a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a></li>
</ul>

View File

@ -1,46 +1,57 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
{% if ipaddress.vrf %}
<li><a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a></li>
{% endif %}
<li>{{ ipaddress }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:ipaddress_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search IPs" />
<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">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ipaddress.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this IP
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ipaddress.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this IP
</a>
{% endif %}
</div>
<h1>{% block title %}{{ ipaddress }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=ipaddress %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ ipaddress.get_absolute_url }}">IP Address</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
{% if ipaddress.vrf %}
<li><a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a></li>
{% endif %}
<li>{{ ipaddress }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:ipaddress_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search IPs" />
<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">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ipaddress.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this IP
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ipaddress.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this IP
</a>
{% endif %}
</div>
<h1>{% block title %}{{ ipaddress }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=ipaddress %}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,152 +1,216 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block title %}{{ prefix }}{% endblock %} {% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
{% if prefix.vrf %}
<li><a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a></li>
{% endif %}
<li>{{ prefix }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:prefix_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search prefixes" />
<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">
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ prefix.vrf.pk }}&site={{ prefix.site.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
<i class="fa fa-plus" aria-hidden="true"></i> Add Child Prefix
</a>
{% endif %}
{% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ prefix.vrf.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
<span class="fa fa-plus" aria-hidden="true"></span>
Add an IP Address
</a>
{% endif %}
{% if perms.ipam.change_prefix %}
<a href="{% url 'ipam:prefix_edit' pk=prefix.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this prefix
</a>
{% endif %}
{% if perms.ipam.delete_prefix %}
<a href="{% url 'ipam:prefix_delete' pk=prefix.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this prefix
</a>
{% endif %}
</div>
<h1>{% block title %}{{ prefix }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=prefix %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a>
</li>
<li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
</li>
<li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
{% include 'ipam/inc/prefix_header.html' with active_tab='prefix' %} <div class="row">
<div class="row"> <div class="col-md-5">
<div class="col-md-5"> <div class="panel panel-default">
<div class="panel panel-default"> <div class="panel-heading">
<div class="panel-heading"> <strong>Prefix</strong>
<strong>Prefix</strong> </div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Family</td>
<td>{{ prefix.get_family_display }}</td>
</tr>
<tr>
<td>VRF</td>
<td>
{% if prefix.vrf %}
<a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a> ({{ prefix.vrf.rd }})
{% else %}
<span>Global</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if prefix.tenant %}
{% if prefix.tenant.group %}
<a href="{{ prefix.tenant.group.get_absolute_url }}">{{ prefix.tenant.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ prefix.tenant.get_absolute_url }}">{{ prefix.tenant }}</a>
{% elif prefix.vrf.tenant %}
{% if prefix.vrf.tenant.group %}
<a href="{{ prefix.vrf.tenant.group.get_absolute_url }}">{{ prefix.vrf.tenant.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ prefix.vrf.tenant.get_absolute_url }}">{{ prefix.vrf.tenant }}</a>
<label class="label label-info">Inherited</label>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Aggregate</td>
<td>
{% if aggregate %}
<a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
{% else %}
<span class="text-warning">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Site</td>
<td>
{% if prefix.site %}
{% if prefix.site.region %}
<a href="{{ prefix.site.region.get_absolute_url }}">{{ prefix.site.region }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'dcim:site' slug=prefix.site.slug %}">{{ prefix.site }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>VLAN</td>
<td>
{% if prefix.vlan %}
{% if prefix.vlan.group %}
<a href="{{ prefix.vlan.group.get_absolute_url }}">{{ prefix.vlan.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'ipam:vlan' pk=prefix.vlan.pk %}">{{ prefix.vlan.display_name }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Status</td>
<td>
<span class="label label-{{ prefix.get_status_class }}">{{ prefix.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Role</td>
<td>
{% if prefix.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ prefix.role.slug }}">{{ prefix.role }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if prefix.description %}
<span>{{ prefix.description }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Is a pool</td>
<td>
{% if prefix.is_pool %}
<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>Tags</td>
<td>
{% for tag in prefix.tags.all %}
{% tag 'ipam:prefix_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
<tr>
<td>Utilization</td>
<td>{% utilization_graph prefix.get_utilization %}</td>
</tr>
</table>
</div> </div>
<table class="table table-hover panel-body attr-table"> {% with prefix.get_custom_fields as custom_fields %}
<tr> {% include 'inc/custom_fields_panel.html' %}
<td>Family</td> {% endwith %}
<td>{{ prefix.get_family_display }}</td> <br />
</tr>
<tr>
<td>VRF</td>
<td>
{% if prefix.vrf %}
<a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a> ({{ prefix.vrf.rd }})
{% else %}
<span>Global</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if prefix.tenant %}
{% if prefix.tenant.group %}
<a href="{{ prefix.tenant.group.get_absolute_url }}">{{ prefix.tenant.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ prefix.tenant.get_absolute_url }}">{{ prefix.tenant }}</a>
{% elif prefix.vrf.tenant %}
{% if prefix.vrf.tenant.group %}
<a href="{{ prefix.vrf.tenant.group.get_absolute_url }}">{{ prefix.vrf.tenant.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ prefix.vrf.tenant.get_absolute_url }}">{{ prefix.vrf.tenant }}</a>
<label class="label label-info">Inherited</label>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Aggregate</td>
<td>
{% if aggregate %}
<a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
{% else %}
<span class="text-warning">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Site</td>
<td>
{% if prefix.site %}
{% if prefix.site.region %}
<a href="{{ prefix.site.region.get_absolute_url }}">{{ prefix.site.region }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'dcim:site' slug=prefix.site.slug %}">{{ prefix.site }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>VLAN</td>
<td>
{% if prefix.vlan %}
{% if prefix.vlan.group %}
<a href="{{ prefix.vlan.group.get_absolute_url }}">{{ prefix.vlan.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'ipam:vlan' pk=prefix.vlan.pk %}">{{ prefix.vlan.display_name }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Status</td>
<td>
<span class="label label-{{ prefix.get_status_class }}">{{ prefix.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Role</td>
<td>
{% if prefix.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ prefix.role.slug }}">{{ prefix.role }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if prefix.description %}
<span>{{ prefix.description }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Is a pool</td>
<td>
{% if prefix.is_pool %}
<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>Tags</td>
<td>
{% for tag in prefix.tags.all %}
{% tag 'ipam:prefix_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
<tr>
<td>Utilization</td>
<td>{% utilization_graph prefix.get_utilization %}</td>
</tr>
</table>
</div> </div>
{% with prefix.get_custom_fields as custom_fields %} <div class="col-md-7">
{% include 'inc/custom_fields_panel.html' %} {% if duplicate_prefix_table.rows %}
{% endwith %} {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
<br /> {% endif %}
</div> {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
<div class="col-md-7"> </div>
{% if duplicate_prefix_table.rows %} </div>
{% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% endif %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,8 @@
{% extends '_base.html' %} {% extends 'ipam/prefix.html' %}
{% block title %}{{ prefix }} - IP Addresses{% endblock %} {% block title %}{{ block.super }} - IP Addresses{% endblock %}
{% block content %} {% block content %}
{% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} {% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}

View File

@ -1,9 +1,8 @@
{% extends '_base.html' %} {% extends 'ipam/prefix.html' %}
{% block title %}{{ prefix }} - Prefixes{% endblock %} {% block title %}{{ block.super }} - Prefixes{% endblock %}
{% block content %} {% block content %}
{% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %} {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}

View File

@ -1,118 +1,173 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block content %} {% block header %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %} <div class="row">
<div class="row"> <div class="col-sm-8 col-md-9">
<div class="col-md-6"> <ol class="breadcrumb">
<div class="panel panel-default"> <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
<div class="panel-heading"> {% if vlan.site %}
<strong>VLAN</strong> <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
</div> {% endif %}
<table class="table table-hover panel-body attr-table"> {% if vlan.group %}
<tr> <li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
<td>Site</td> {% endif %}
<td> <li>{{ vlan }}</li>
{% if vlan.site %} </ol>
{% if vlan.site.region %}
<a href="{{ vlan.site.region.get_absolute_url }}">{{ vlan.site.region }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Group</td>
<td>
{% if vlan.group %}
<a href="{{ vlan.group.get_absolute_url }}">{{ vlan.group }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>VLAN ID</td>
<td>{{ vlan.vid }}</td>
</tr>
<tr>
<td>Name</td>
<td>{{ vlan.name }}</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if vlan.tenant %}
{% if vlan.tenant.group %}
<a href="{{ vlan.tenant.group.get_absolute_url }}">{{ vlan.tenant.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Status</td>
<td>
<span class="label label-{{ vlan.get_status_class }}">{{ vlan.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Role</td>
<td>
{% if vlan.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ vlan.role.slug }}">{{ vlan.role }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if vlan.description %}
{{ vlan.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tags</td>
<td>
{% for tag in vlan.tags.all %}
{% tag 'ipam:vlan_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table>
</div> </div>
{% with vlan.get_custom_fields as custom_fields %} <div class="col-sm-4 col-md-3">
{% include 'inc/custom_fields_panel.html' %} <form action="{% url 'ipam:vlan_list' %}" method="get">
{% endwith %} <div class="input-group">
</div> <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
<div class="col-md-6"> <span class="input-group-btn">
<div class="panel panel-default"> <button type="submit" class="btn btn-primary">
<div class="panel-heading"> <span class="fa fa-search" aria-hidden="true"></span>
<strong>Prefixes</strong> </button>
</span>
</div> </div>
{% include 'responsive_table.html' with table=prefix_table %} </form>
{% if perms.ipam.add_prefix %}
<div class="panel-footer text-right">
<a href="{% url 'ipam:prefix_add' %}?{% if vlan.tenant %}tenant={{ vlan.tenant.pk }}&{% endif %}site={{ vlan.site.pk }}&vlan={{ vlan.pk }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add a prefix
</a>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> <div class="pull-right">
{% if perms.ipam.change_vlan %}
<a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this VLAN
</a>
{% endif %}
{% if perms.ipam.delete_vlan %}
<a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this VLAN
</a>
{% endif %}
</div>
<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vlan %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
</li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>VLAN</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Site</td>
<td>
{% if vlan.site %}
{% if vlan.site.region %}
<a href="{{ vlan.site.region.get_absolute_url }}">{{ vlan.site.region }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Group</td>
<td>
{% if vlan.group %}
<a href="{{ vlan.group.get_absolute_url }}">{{ vlan.group }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>VLAN ID</td>
<td>{{ vlan.vid }}</td>
</tr>
<tr>
<td>Name</td>
<td>{{ vlan.name }}</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if vlan.tenant %}
{% if vlan.tenant.group %}
<a href="{{ vlan.tenant.group.get_absolute_url }}">{{ vlan.tenant.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Status</td>
<td>
<span class="label label-{{ vlan.get_status_class }}">{{ vlan.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Role</td>
<td>
{% if vlan.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ vlan.role.slug }}">{{ vlan.role }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if vlan.description %}
{{ vlan.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tags</td>
<td>
{% for tag in vlan.tags.all %}
{% tag 'ipam:vlan_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
{% endfor %}
</td>
</tr>
</table>
</div>
{% with vlan.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Prefixes</strong>
</div>
{% include 'responsive_table.html' with table=prefix_table %}
{% if perms.ipam.add_prefix %}
<div class="panel-footer text-right">
<a href="{% url 'ipam:prefix_add' %}?{% if vlan.tenant %}tenant={{ vlan.tenant.pk }}&{% endif %}site={{ vlan.site.pk }}&vlan={{ vlan.pk }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add a prefix
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,8 @@
{% extends '_base.html' %} {% extends 'ipam/vlan.html' %}
{% block title %}{{ vlan }} - Members{% endblock %} {% block title %}{{ block.super }} - Members{% endblock %}
{% block content %} {% block content %}
{% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}

View File

@ -1,43 +1,54 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:vrf_list' %}">VRFs</a></li>
<li>{{ vrf }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:vrf_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search VRFs" />
<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">
{% if perms.ipam.change_vrf %}
<a href="{% url 'ipam:vrf_edit' pk=vrf.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this VRF
</a>
{% endif %}
{% if perms.ipam.delete_vrf %}
<a href="{% url 'ipam:vrf_delete' pk=vrf.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this VRF
</a>
{% endif %}
</div>
<h1>{% block title %}VRF {{ vrf }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vrf %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ aggregate.get_absolute_url }}">VRF</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipam:vrf_list' %}">VRFs</a></li>
<li>{{ vrf }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipam:vrf_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search VRFs" />
<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">
{% if perms.ipam.change_vrf %}
<a href="{% url 'ipam:vrf_edit' pk=vrf.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this VRF
</a>
{% endif %}
{% if perms.ipam.delete_vrf %}
<a href="{% url 'ipam:vrf_delete' pk=vrf.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this VRF
</a>
{% endif %}
</div>
<h1>{% block title %}VRF {{ vrf }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vrf %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -3,32 +3,43 @@
{% load helpers %} {% load helpers %}
{% load secret_helpers %} {% load secret_helpers %}
{% block content %} {% block header %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li> <li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li>
<li><a href="{% url 'secrets:secret_list' %}?role={{ secret.role.slug }}">{{ secret.role }}</a></li> <li><a href="{% url 'secrets:secret_list' %}?role={{ secret.role.slug }}">{{ secret.role }}</a></li>
<li>{{ secret.device }}{% if secret.name %} ({{ secret.name }}){% endif %}</li> <li>{{ secret.device }}{% if secret.name %} ({{ secret.name }}){% endif %}</li>
</ol> </ol>
</div>
</div> </div>
</div> <div class="pull-right">
<div class="pull-right"> {% if perms.secrets.change_secret %}
{% if perms.secrets.change_secret %} <a href="{% url 'secrets:secret_edit' pk=secret.pk %}" class="btn btn-warning">
<a href="{% url 'secrets:secret_edit' pk=secret.pk %}" class="btn btn-warning"> <span class="fa fa-pencil" aria-hidden="true"></span>
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this secret
Edit this secret </a>
</a> {% endif %}
{% endif %} {% if perms.secrets.delete_secret %}
{% if perms.secrets.delete_secret %} <a href="{% url 'secrets:secret_delete' pk=secret.pk %}" class="btn btn-danger">
<a href="{% url 'secrets:secret_delete' pk=secret.pk %}" class="btn btn-danger"> <span class="fa fa-trash" aria-hidden="true"></span>
<span class="fa fa-trash" aria-hidden="true"></span> Delete this secret
Delete this secret </a>
</a> {% endif %}
{% endif %} </div>
</div> <h1>{% block title %}{{ secret }}{% endblock %}</h1>
<h1>{% block title %}{{ secret }}{% endblock %}</h1> {% include 'inc/created_updated.html' with obj=secret %}
{% include 'inc/created_updated.html' with obj=secret %} <ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ secret.get_absolute_url }}">Secret</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,46 +1,57 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></li>
{% if tenant.group %}
<li><a href="{% url 'tenancy:tenant_list' %}?group={{ tenant.group.slug }}">{{ tenant.group }}</a></li>
{% endif %}
<li>{{ tenant }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'tenancy:tenant_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Name" />
<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">
{% if perms.tenancy.change_tenant %}
<a href="{% url 'tenancy:tenant_edit' slug=tenant.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this tenant
</a>
{% endif %}
{% if perms.tenancy.delete_tenant %}
<a href="{% url 'tenancy:tenant_delete' slug=tenant.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this tenant
</a>
{% endif %}
</div>
<h1>{% block title %}{{ tenant }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=tenant %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ tenant.get_absolute_url }}">Tenant</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></li>
{% if tenant.group %}
<li><a href="{% url 'tenancy:tenant_list' %}?group={{ tenant.group.slug }}">{{ tenant.group }}</a></li>
{% endif %}
<li>{{ tenant }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'tenancy:tenant_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Name" />
<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">
{% if perms.tenancy.change_tenant %}
<a href="{% url 'tenancy:tenant_edit' slug=tenant.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this tenant
</a>
{% endif %}
{% if perms.tenancy.delete_tenant %}
<a href="{% url 'tenancy:tenant_delete' slug=tenant.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this tenant
</a>
{% endif %}
</div>
<h1>{% block title %}{{ tenant }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=tenant %}
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,46 +1,57 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row" xmlns="http://www.w3.org/1999/html">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
{% if cluster.group %}
<li><a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a></li>
{% endif %}
<li>{{ cluster }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'virtualization:cluster_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search clusters" />
<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">
{% if perms.virtualization.change_cluster %}
<a href="{% url 'virtualization:cluster_edit' pk=cluster.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this cluster
</a>
{% endif %}
{% if perms.dcim.delete_cluster %}
<a href="{% url 'virtualization:cluster_delete' pk=cluster.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this cluster
</a>
{% endif %}
</div>
<h1>{% block title %}{{ cluster }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=cluster %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ cluster.get_absolute_url }}">Cluster</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row" xmlns="http://www.w3.org/1999/html">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
{% if cluster.group %}
<li><a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a></li>
{% endif %}
<li>{{ cluster }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'virtualization:cluster_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search clusters" />
<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">
{% if perms.virtualization.change_cluster %}
<a href="{% url 'virtualization:cluster_edit' pk=cluster.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this cluster
</a>
{% endif %}
{% if perms.dcim.delete_cluster %}
<a href="{% url 'virtualization:cluster_delete' pk=cluster.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this cluster
</a>
{% endif %}
</div>
<h1>{% block title %}{{ cluster }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=cluster %}
<div class="row"> <div class="row">
<div class="col-md-5"> <div class="col-md-5">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,45 +1,56 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
{% if virtualmachine.cluster %}
<li><a href="{{ virtualmachine.cluster.get_absolute_url }}">{{ virtualmachine.cluster }}</a></li>
{% endif %}
<li>{{ virtualmachine }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'virtualization:virtualmachine_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search virtual machines" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.virtualization.change_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_edit' pk=virtualmachine.pk %}" class="btn btn-warning">
<span class="fa fa-pencil"></span>
Edit this VM
</a>
{% endif %}
{% if perms.virtualization.delete_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_delete' pk=virtualmachine.pk %}" class="btn btn-danger">
<span class="fa fa-trash"></span>
Delete this VM
</a>
{% endif %}
</div>
<h1>{% block title %}{{ virtualmachine }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=virtualmachine %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
{% if vm.cluster %}
<li><a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a></li>
{% endif %}
<li>{{ vm }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'virtualization:virtualmachine_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search virtual machines" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.virtualization.change_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_edit' pk=vm.pk %}" class="btn btn-warning">
<span class="fa fa-pencil"></span>
Edit this VM
</a>
{% endif %}
{% if perms.virtualization.delete_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_delete' pk=vm.pk %}" class="btn btn-danger">
<span class="fa fa-trash"></span>
Delete this VM
</a>
{% endif %}
</div>
<h1>{% block title %}{{ vm }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vm %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">
@ -49,19 +60,19 @@
<table class="table table-hover panel-body attr-table"> <table class="table table-hover panel-body attr-table">
<tr> <tr>
<td>Name</td> <td>Name</td>
<td>{{ vm }}</td> <td>{{ virtualmachine }}</td>
</tr> </tr>
<tr> <tr>
<td>Status</td> <td>Status</td>
<td> <td>
<span class="label label-{{ vm.get_status_class }}">{{ vm.get_status_display }}</span> <span class="label label-{{ virtualmachine.get_status_class }}">{{ virtualmachine.get_status_display }}</span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Role</td> <td>Role</td>
<td> <td>
{% if vm.role %} {% if virtualmachine.role %}
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ vm.role.slug }}">{{ vm.role }}</a> <a href="{% url 'virtualization:virtualmachine_list' %}?role={{ virtualmachine.role.slug }}">{{ virtualmachine.role }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}
@ -70,8 +81,8 @@
<tr> <tr>
<td>Platform</td> <td>Platform</td>
<td> <td>
{% if vm.platform %} {% if virtualmachine.platform %}
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ vm.platform.slug }}">{{ vm.platform }}</a> <a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ virtualmachine.platform.slug }}">{{ virtualmachine.platform }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}
@ -80,12 +91,12 @@
<tr> <tr>
<td>Tenant</td> <td>Tenant</td>
<td> <td>
{% if vm.tenant %} {% if virtualmachine.tenant %}
{% if vm.tenant.group %} {% if virtualmachine.tenant.group %}
<a href="{{ vm.tenant.group.get_absolute_url }}">{{ vm.tenant.group }}</a> <a href="{{ virtualmachine.tenant.group.get_absolute_url }}">{{ virtualmachine.tenant.group }}</a>
<i class="fa fa-angle-right"></i> <i class="fa fa-angle-right"></i>
{% endif %} {% endif %}
<a href="{{ vm.tenant.get_absolute_url }}">{{ vm.tenant }}</a> <a href="{{ virtualmachine.tenant.get_absolute_url }}">{{ virtualmachine.tenant }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}
@ -94,12 +105,12 @@
<tr> <tr>
<td>Primary IPv4</td> <td>Primary IPv4</td>
<td> <td>
{% if vm.primary_ip4 %} {% if virtualmachine.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip4.pk %}">{{ vm.primary_ip4.address.ip }}</a> <a href="{% url 'ipam:ipaddress' pk=virtualmachine.primary_ip4.pk %}">{{ virtualmachine.primary_ip4.address.ip }}</a>
{% if vm.primary_ip4.nat_inside %} {% if virtualmachine.primary_ip4.nat_inside %}
<span>(NAT for {{ vm.primary_ip4.nat_inside.address.ip }})</span> <span>(NAT for {{ virtualmachine.primary_ip4.nat_inside.address.ip }})</span>
{% elif vm.primary_ip4.nat_outside %} {% elif virtualmachine.primary_ip4.nat_outside %}
<span>(NAT: {{ vm.primary_ip4.nat_outside.address.ip }})</span> <span>(NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }})</span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
@ -109,12 +120,12 @@
<tr> <tr>
<td>Primary IPv6</td> <td>Primary IPv6</td>
<td> <td>
{% if vm.primary_ip6 %} {% if virtualmachine.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip6.pk %}">{{ vm.primary_ip6.address.ip }}</a> <a href="{% url 'ipam:ipaddress' pk=virtualmachine.primary_ip6.pk %}">{{ virtualmachine.primary_ip6.address.ip }}</a>
{% if vm.primary_ip6.nat_inside %} {% if virtualmachine.primary_ip6.nat_inside %}
<span>(NAT for {{ vm.primary_ip6.nat_inside.address.ip }})</span> <span>(NAT for {{ virtualmachine.primary_ip6.nat_inside.address.ip }})</span>
{% elif vm.primary_ip6.nat_outside %} {% elif virtualmachine.primary_ip6.nat_outside %}
<span>(NAT: {{ vm.primary_ip6.nat_outside.address.ip }})</span> <span>(NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }})</span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
@ -124,7 +135,7 @@
<tr> <tr>
<td>Tags</td> <td>Tags</td>
<td> <td>
{% for tag in vm.tags.all %} {% for tag in virtualmachine.tags.all %}
{% tag 'virtualization:virtualmachine_list' tag %} {% tag 'virtualization:virtualmachine_list' tag %}
{% empty %} {% empty %}
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
@ -133,14 +144,14 @@
</tr> </tr>
</table> </table>
</div> </div>
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %} {% include 'inc/custom_fields_panel.html' with custom_fields=virtualmachine.get_custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Comments</strong> <strong>Comments</strong>
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% if vm.comments %} {% if virtualmachine.comments %}
{{ vm.comments|gfm }} {{ virtualmachine.comments|gfm }}
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}
@ -156,16 +167,16 @@
<tr> <tr>
<td>Cluster</td> <td>Cluster</td>
<td> <td>
{% if vm.cluster.group %} {% if virtualmachine.cluster.group %}
<a href="{{ vm.cluster.group.get_absolute_url }}">{{ vm.cluster.group }}</a> <a href="{{ virtualmachine.cluster.group.get_absolute_url }}">{{ virtualmachine.cluster.group }}</a>
<i class="fa fa-angle-right"></i> <i class="fa fa-angle-right"></i>
{% endif %} {% endif %}
<a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a> <a href="{{ virtualmachine.cluster.get_absolute_url }}">{{ virtualmachine.cluster }}</a>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Cluster Type</td> <td>Cluster Type</td>
<td>{{ vm.cluster.type }}</td> <td>{{ virtualmachine.cluster.type }}</td>
</tr> </tr>
</table> </table>
</div> </div>
@ -177,8 +188,8 @@
<tr> <tr>
<td><i class="fa fa-tachometer"></i> Virtual CPUs</td> <td><i class="fa fa-tachometer"></i> Virtual CPUs</td>
<td> <td>
{% if vm.vcpus %} {% if virtualmachine.vcpus %}
{{ vm.vcpus }} {{ virtualmachine.vcpus }}
{% else %} {% else %}
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
{% endif %} {% endif %}
@ -187,8 +198,8 @@
<tr> <tr>
<td><i class="fa fa-microchip"></i> Memory</td> <td><i class="fa fa-microchip"></i> Memory</td>
<td> <td>
{% if vm.memory %} {% if virtualmachine.memory %}
{{ vm.memory }} MB {{ virtualmachine.memory }} MB
{% else %} {% else %}
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
{% endif %} {% endif %}
@ -197,8 +208,8 @@
<tr> <tr>
<td><i class="fa fa-hdd-o"></i> Disk Space</td> <td><i class="fa fa-hdd-o"></i> Disk Space</td>
<td> <td>
{% if vm.disk %} {% if virtualmachine.disk %}
{{ vm.disk }} GB {{ virtualmachine.disk }} GB
{% else %} {% else %}
<span class="text-muted">N/A</span> <span class="text-muted">N/A</span>
{% endif %} {% endif %}
@ -223,7 +234,7 @@
{% endif %} {% endif %}
{% if perms.ipam.add_service %} {% if perms.ipam.add_service %}
<div class="panel-footer text-right"> <div class="panel-footer text-right">
<a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=vm.pk %}" class="btn btn-xs btn-primary"> <a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=virtualmachine.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
</a> </a>
</div> </div>
@ -236,7 +247,7 @@
{% if perms.dcim.change_interface or perms.dcim.delete_interface %} {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="virtual_machine" value="{{ vm.pk }}" /> <input type="hidden" name="virtual_machine" value="{{ virtualmachine.pk }}" />
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
@ -264,7 +275,7 @@
</thead> </thead>
<tbody> <tbody>
{% for iface in interfaces %} {% for iface in interfaces %}
{% include 'dcim/inc/interface.html' with device=vm %} {% include 'dcim/inc/interface.html' with device=virtualmachine %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td> <td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
@ -275,21 +286,21 @@
{% if perms.dcim.add_interface or perms.dcim.delete_interface %} {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
<div class="panel-footer"> <div class="panel-footer">
{% if interfaces and perms.dcim.change_interface %} {% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ vm.get_absolute_url }}" class="btn btn-warning btn-xs"> <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs"> <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=vm.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_interface %} {% if perms.dcim.add_interface %}
<div class="pull-right"> <div class="pull-right">
<a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs"> <a href="{% url 'virtualization:interface_add' pk=virtualmachine.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a> </a>
</div> </div>

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0004_tags'),
]
operations = [
migrations.AddField(
model_name='tenantgroup',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='tenantgroup',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='tenant',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='tenant',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -7,11 +7,11 @@ from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from extras.models import CustomFieldModel from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel from utilities.models import ChangeLoggedModel
@python_2_unicode_compatible @python_2_unicode_compatible
class TenantGroup(models.Model): class TenantGroup(ChangeLoggedModel):
""" """
An arbitrary collection of Tenants. An arbitrary collection of Tenants.
""" """
@ -23,9 +23,8 @@ class TenantGroup(models.Model):
unique=True unique=True
) )
csv_headers = ['name', 'slug']
serializer = 'tenancy.api.serializers.TenantGroupSerializer' serializer = 'tenancy.api.serializers.TenantGroupSerializer'
csv_headers = ['name', 'slug']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -44,7 +43,7 @@ class TenantGroup(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Tenant(CreatedUpdatedModel, CustomFieldModel): class Tenant(ChangeLoggedModel, CustomFieldModel):
""" """
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
department. department.
@ -79,9 +78,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
serializer = 'tenancy.api.serializers.TenantSerializer' serializer = 'tenancy.api.serializers.TenantSerializer'
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
class Meta: class Meta:
ordering = ['group', 'name'] ordering = ['group', 'name']

View File

@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
TENANTGROUP_ACTIONS = """ TENANTGROUP_ACTIONS = """
<a href="{% url 'tenancy:tenantgroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.tenancy.change_tenantgroup %} {% if perms.tenancy.change_tenantgroup %}
<a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views from . import views
from .models import Tenant, TenantGroup
app_name = 'tenancy' app_name = 'tenancy'
urlpatterns = [ urlpatterns = [
@ -13,6 +15,7 @@ urlpatterns = [
url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
url(r'^tenant-groups/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
# Tenants # Tenants
url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'), url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
@ -23,5 +26,6 @@ urlpatterns = [
url(r'^tenants/(?P<slug>[\w-]+)/$', views.TenantView.as_view(), name='tenant'), url(r'^tenants/(?P<slug>[\w-]+)/$', views.TenantView.as_view(), name='tenant'),
url(r'^tenants/(?P<slug>[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'), url(r'^tenants/(?P<slug>[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'),
url(r'^tenants/(?P<slug>[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'), url(r'^tenants/(?P<slug>[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'),
url(r'^tenants/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
] ]

View File

@ -16,6 +16,8 @@ from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError
from rest_framework.viewsets import GenericViewSet, ViewSet from rest_framework.viewsets import GenericViewSet, ViewSet
from .utils import dynamic_import
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@ -24,6 +26,20 @@ class ServiceUnavailable(APIException):
default_detail = "Service temporarily unavailable, please try again later." default_detail = "Service temporarily unavailable, please try again later."
def get_serializer_for_model(model, prefix=''):
"""
Dynamically resolve and return the appropriate serializer for a model.
"""
app_name, model_name = model._meta.label.split('.')
serializer_name = '{}.api.serializers.{}{}Serializer'.format(
app_name, prefix, model_name
)
try:
return dynamic_import(serializer_name)
except ImportError:
return None
# #
# Authentication # Authentication
# #

View File

@ -2,10 +2,38 @@ from __future__ import unicode_literals
from django.db import models from django.db import models
from extras.models import ObjectChange
from utilities.utils import serialize_object
class CreatedUpdatedModel(models.Model):
created = models.DateField(auto_now_add=True) class ChangeLoggedModel(models.Model):
last_updated = models.DateTimeField(auto_now=True) """
An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be
null to facilitate adding these fields to existing instances via a database migration.
"""
created = models.DateField(
auto_now_add=True,
blank=True,
null=True
)
last_updated = models.DateTimeField(
auto_now=True,
blank=True,
null=True
)
class Meta: class Meta:
abstract = True abstract = True
def log_change(self, user, request_id, action):
"""
Create a new ObjectChange representing a change made to this object. This will typically be called automatically
by extras.middleware.ChangeLoggingMiddleware.
"""
ObjectChange(
user=user,
request_id=request_id,
changed_object=self,
action=action,
object_data=serialize_object(self)
).save()

View File

@ -1,8 +1,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime import datetime
import json
import six import six
from django.core.serializers import serialize
from django.http import HttpResponse from django.http import HttpResponse
@ -82,3 +84,15 @@ def dynamic_import(name):
for comp in components[1:]: for comp in components[1:]:
mod = getattr(mod, comp) mod = getattr(mod, comp)
return mod return mod
def serialize_object(obj, extra=None):
"""
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
change logging, not the REST API.) Optionally include a dictionary to supplement the object data.
"""
json_str = serialize('json', [obj])
data = json.loads(json_str)[0]['fields']
if extra is not None:
data.update(extra)
return data

View File

@ -19,7 +19,7 @@ from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.webhooks import bulk_operation_signal from extras.webhooks import bulk_operation_signal
from utilities.utils import queryset_to_csv from utilities.utils import queryset_to_csv
from utilities.forms import BootstrapMixin, CSVDataField from utilities.forms import BootstrapMixin, CSVDataField
@ -213,11 +213,6 @@ class ObjectEditView(GetReturnURLMixin, View):
msg = '{} {}'.format(msg, escape(obj)) msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
if obj_created:
UserAction.objects.log_create(request.user, obj, msg)
else:
UserAction.objects.log_edit(request.user, obj, msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.get_full_path()) return redirect(request.get_full_path())
@ -279,7 +274,6 @@ class ObjectDeleteView(GetReturnURLMixin, View):
msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj) msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
messages.success(request, msg) messages.success(request, msg)
UserAction.objects.log_delete(request.user, obj, msg)
return_url = form.cleaned_data.get('return_url') return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, host=request.get_host()): if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
@ -365,7 +359,6 @@ class BulkCreateView(View):
# If we make it to this point, validation has succeeded on all new objects. # If we make it to this point, validation has succeeded on all new objects.
msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
messages.success(request, msg) messages.success(request, msg)
UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.path) return redirect(request.path)
@ -450,7 +443,6 @@ class BulkImportView(View):
if new_objs: if new_objs:
msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural) msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
messages.success(request, msg) messages.success(request, msg)
UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
return render(request, "import_success.html", { return render(request, "import_success.html", {
'table': obj_table, 'table': obj_table,
@ -566,7 +558,6 @@ class BulkEditView(View):
if updated_count: if updated_count:
msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
messages.success(self.request, msg) messages.success(self.request, msg)
UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
return redirect(return_url) return redirect(return_url)
@ -661,7 +652,6 @@ class BulkDeleteView(View):
msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural) msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
messages.success(request, msg) messages.success(request, msg)
UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
return redirect(return_url) return redirect(return_url)
else: else:

View File

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0006_tags'),
]
operations = [
migrations.AddField(
model_name='clustergroup',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='clustergroup',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='clustertype',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='clustertype',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='cluster',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='cluster',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='virtualmachine',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='virtualmachine',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -10,7 +10,7 @@ from taggit.managers import TaggableManager
from dcim.models import Device from dcim.models import Device
from extras.models import CustomFieldModel from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel from utilities.models import ChangeLoggedModel
from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
@ -19,7 +19,7 @@ from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSE
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class ClusterType(models.Model): class ClusterType(ChangeLoggedModel):
""" """
A type of Cluster. A type of Cluster.
""" """
@ -31,6 +31,7 @@ class ClusterType(models.Model):
unique=True unique=True
) )
serializer = 'virtualization.api.serializers.ClusterTypeSerializer'
csv_headers = ['name', 'slug'] csv_headers = ['name', 'slug']
class Meta: class Meta:
@ -54,7 +55,7 @@ class ClusterType(models.Model):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class ClusterGroup(models.Model): class ClusterGroup(ChangeLoggedModel):
""" """
An organizational group of Clusters. An organizational group of Clusters.
""" """
@ -66,9 +67,8 @@ class ClusterGroup(models.Model):
unique=True unique=True
) )
csv_headers = ['name', 'slug']
serializer = 'virtualization.api.serializers.ClusterGroupSerializer' serializer = 'virtualization.api.serializers.ClusterGroupSerializer'
csv_headers = ['name', 'slug']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -91,7 +91,7 @@ class ClusterGroup(models.Model):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class Cluster(CreatedUpdatedModel, CustomFieldModel): class Cluster(ChangeLoggedModel, CustomFieldModel):
""" """
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
""" """
@ -129,9 +129,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['name', 'type', 'group', 'site', 'comments']
serializer = 'virtualization.api.serializers.ClusterSerializer' serializer = 'virtualization.api.serializers.ClusterSerializer'
csv_headers = ['name', 'type', 'group', 'site', 'comments']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -169,7 +168,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): class VirtualMachine(ChangeLoggedModel, CustomFieldModel):
""" """
A virtual machine which runs inside a Cluster. A virtual machine which runs inside a Cluster.
""" """
@ -251,12 +250,11 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager() tags = TaggableManager()
serializer = 'virtualization.api.serializers.VirtualMachineSerializer'
csv_headers = [ csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
] ]
serializer = 'virtualization.api.serializers.VirtualMachineSerializer'
class Meta: class Meta:
ordering = ['name'] ordering = ['name']

View File

@ -9,12 +9,18 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
CLUSTERTYPE_ACTIONS = """ CLUSTERTYPE_ACTIONS = """
<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.virtualization.change_clustertype %} {% if perms.virtualization.change_clustertype %}
<a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
CLUSTERGROUP_ACTIONS = """ CLUSTERGROUP_ACTIONS = """
<a href="{% url 'virtualization:clustergroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.virtualization.change_clustergroup %} {% if perms.virtualization.change_clustergroup %}
<a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}

View File

@ -2,8 +2,10 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from extras.views import ObjectChangeLogView
from ipam.views import ServiceCreateView from ipam.views import ServiceCreateView
from . import views from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
app_name = 'virtualization' app_name = 'virtualization'
urlpatterns = [ urlpatterns = [
@ -14,6 +16,7 @@ urlpatterns = [
url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
url(r'^cluster-types/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
# Cluster groups # Cluster groups
url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'), url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
@ -21,6 +24,7 @@ urlpatterns = [
url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
url(r'^cluster-groups/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
# Clusters # Clusters
url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'), url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
@ -31,6 +35,7 @@ urlpatterns = [
url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'), url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'), url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'), url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
url(r'^clusters/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
@ -43,6 +48,7 @@ urlpatterns = [
url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'), url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
url(r'^virtual-machines/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces # VM interfaces

View File

@ -258,12 +258,12 @@ class VirtualMachineView(View):
def get(self, request, pk): def get(self, request, pk):
vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk) virtualmachine = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
interfaces = Interface.objects.filter(virtual_machine=vm) interfaces = Interface.objects.filter(virtual_machine=virtualmachine)
services = Service.objects.filter(virtual_machine=vm) services = Service.objects.filter(virtual_machine=virtualmachine)
return render(request, 'virtualization/virtualmachine.html', { return render(request, 'virtualization/virtualmachine.html', {
'vm': vm, 'virtualmachine': virtualmachine,
'interfaces': interfaces, 'interfaces': interfaces,
'services': services, 'services': services,
}) })