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
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.fields import ASNField
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
@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
stores information pertinent to the user's relationship with the Provider.
@ -59,9 +59,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager()
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
serializer = 'circuits.api.serializers.ProviderSerializer'
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class Meta:
ordering = ['name']
@ -86,7 +85,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
@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
"Long Haul," "Metro," or "Out-of-Band".
@ -99,6 +98,7 @@ class CircuitType(models.Model):
unique=True
)
serializer = 'circuits.api.serializers.CircuitTypeSerializer'
csv_headers = ['name', 'slug']
class Meta:
@ -118,7 +118,7 @@ class CircuitType(models.Model):
@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
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()
serializer = 'circuits.api.serializers.CircuitSerializer'
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]
serializer = 'circuits.api.serializers.CircuitSerializer'
class Meta:
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']

View File

@ -9,6 +9,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Circuit, CircuitType, Provider
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 %}
<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 %}

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views
from .models import Circuit, CircuitType, Provider
app_name = 'circuits'
urlpatterns = [
@ -16,6 +18,7 @@ urlpatterns = [
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-]+)/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
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/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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits
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+)/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+)/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'),
# 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):
dependencies = [
('dcim', '0059_devicetype_add_created_updated_times'),
('dcim', '0058_relax_rack_naming_constraints'),
]
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 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 utilities.fields import ColorField, NullableCharField
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 .fields import ASNField, MACAddressField
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
#
@python_2_unicode_compatible
class Region(MPTTModel):
class Region(MPTTModel, ChangeLoggedModel):
"""
Sites can be grouped within geographic Regions.
"""
@ -53,6 +79,7 @@ class Region(MPTTModel):
unique=True
)
serializer = 'dcim.api.serializers.RegionSerializer'
csv_headers = ['name', 'slug', 'parent']
class MPTTMeta:
@ -81,7 +108,7 @@ class SiteManager(NaturalOrderByManager):
@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
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()
tags = TaggableManager()
serializer = 'dcim.api.serializers.SiteSerializer'
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
]
serializer = 'dcim.api.serializers.SiteSerializer'
class Meta:
ordering = ['name']
@ -245,7 +271,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
#
@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
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'
)
csv_headers = ['site', 'name', 'slug']
serializer = 'dcim.api.serializers.RackGroupSerializer'
csv_headers = ['site', 'name', 'slug']
class Meta:
ordering = ['site', 'name']
@ -287,7 +312,7 @@ class RackGroup(models.Model):
@python_2_unicode_compatible
class RackRole(models.Model):
class RackRole(ChangeLoggedModel):
"""
Racks can be organized by functional role, similar to Devices.
"""
@ -300,6 +325,7 @@ class RackRole(models.Model):
)
color = ColorField()
serializer = 'dcim.api.serializers.RackRoleSerializer'
csv_headers = ['name', 'slug', 'color']
class Meta:
@ -324,7 +350,7 @@ class RackManager(NaturalOrderByManager):
@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.
Each Rack is assigned to a Site and (optionally) a RackGroup.
@ -406,13 +432,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
objects = RackManager()
tags = TaggableManager()
serializer = 'dcim.api.serializers.RackSerializer'
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
'desc_units', 'comments',
]
serializer = 'dcim.api.serializers.RackSerializer'
class Meta:
ordering = ['site', 'group', 'name']
unique_together = [
@ -584,7 +609,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class RackReservation(models.Model):
class RackReservation(ChangeLoggedModel):
"""
One or more reserved units within a Rack.
"""
@ -596,9 +621,6 @@ class RackReservation(models.Model):
units = ArrayField(
base_field=models.PositiveSmallIntegerField()
)
created = models.DateTimeField(
auto_now_add=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@ -614,6 +636,8 @@ class RackReservation(models.Model):
max_length=100
)
serializer = 'dcim.api.serializers.RackReservationSerializer'
class Meta:
ordering = ['created']
@ -661,7 +685,7 @@ class RackReservation(models.Model):
#
@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.
"""
@ -673,6 +697,7 @@ class Manufacturer(models.Model):
unique=True
)
serializer = 'dcim.api.serializers.ManufacturerSerializer'
csv_headers = ['name', 'slug']
class Meta:
@ -692,7 +717,7 @@ class Manufacturer(models.Model):
@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
well as high-level functional role(s).
@ -767,6 +792,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager()
serializer = 'dcim.api.serializers.DeviceTypeSerializer'
csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
@ -866,7 +892,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class ConsolePortTemplate(models.Model):
class ConsolePortTemplate(ComponentModel):
"""
A template for a ConsolePort to be created for a new Device.
"""
@ -886,9 +912,12 @@ class ConsolePortTemplate(models.Model):
def __str__(self):
return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible
class ConsoleServerPortTemplate(models.Model):
class ConsoleServerPortTemplate(ComponentModel):
"""
A template for a ConsoleServerPort to be created for a new Device.
"""
@ -908,9 +937,12 @@ class ConsoleServerPortTemplate(models.Model):
def __str__(self):
return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible
class PowerPortTemplate(models.Model):
class PowerPortTemplate(ComponentModel):
"""
A template for a PowerPort to be created for a new Device.
"""
@ -930,9 +962,12 @@ class PowerPortTemplate(models.Model):
def __str__(self):
return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible
class PowerOutletTemplate(models.Model):
class PowerOutletTemplate(ComponentModel):
"""
A template for a PowerOutlet to be created for a new Device.
"""
@ -952,9 +987,12 @@ class PowerOutletTemplate(models.Model):
def __str__(self):
return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible
class InterfaceTemplate(models.Model):
class InterfaceTemplate(ComponentModel):
"""
A template for a physical data interface on a new Device.
"""
@ -984,9 +1022,12 @@ class InterfaceTemplate(models.Model):
def __str__(self):
return self.name
def get_component_parent(self):
return self.device_type
@python_2_unicode_compatible
class DeviceBayTemplate(models.Model):
class DeviceBayTemplate(ComponentModel):
"""
A template for a DeviceBay to be created for a new parent Device.
"""
@ -1006,13 +1047,16 @@ class DeviceBayTemplate(models.Model):
def __str__(self):
return self.name
def get_component_parent(self):
return self.device_type
#
# Devices
#
@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
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'
)
serializer = 'dcim.api.serializers.DeviceRoleSerializer'
csv_headers = ['name', 'slug', 'color', 'vm_role']
class Meta:
@ -1053,7 +1098,7 @@ class DeviceRole(models.Model):
@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".
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'
)
serializer = 'dcim.api.serializers.PlatformSerializer'
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
class Meta:
@ -1112,7 +1158,7 @@ class DeviceManager(NaturalOrderByManager):
@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,
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()
tags = TaggableManager()
serializer = 'dcim.api.serializers.DeviceSerializer'
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
]
serializer = 'dcim.api.serializers.DeviceSerializer'
class Meta:
ordering = ['name']
unique_together = [
@ -1501,7 +1546,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
#
@python_2_unicode_compatible
class ConsolePort(models.Model):
class ConsolePort(ComponentModel):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@ -1538,6 +1583,9 @@ class ConsolePort(models.Model):
def get_absolute_url(self):
return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def to_csv(self):
return (
self.cs_port.device.identifier if self.cs_port else None,
@ -1563,7 +1611,7 @@ class ConsoleServerPortManager(models.Manager):
@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.
"""
@ -1587,6 +1635,9 @@ class ConsoleServerPort(models.Model):
def get_absolute_url(self):
return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def clean(self):
# Check that the parent device's DeviceType is a console server
@ -1604,7 +1655,7 @@ class ConsoleServerPort(models.Model):
#
@python_2_unicode_compatible
class PowerPort(models.Model):
class PowerPort(ComponentModel):
"""
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):
return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def to_csv(self):
return (
self.power_outlet.device.identifier if self.power_outlet else None,
@ -1665,7 +1719,7 @@ class PowerOutletManager(models.Manager):
@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.
"""
@ -1689,6 +1743,9 @@ class PowerOutlet(models.Model):
def get_absolute_url(self):
return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def clean(self):
# Check that the parent device's DeviceType is a PDU
@ -1706,7 +1763,7 @@ class PowerOutlet(models.Model):
#
@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
Interface via the creation of an InterfaceConnection.
@ -1796,6 +1853,9 @@ class Interface(models.Model):
def get_absolute_url(self):
return self.parent.get_absolute_url()
def get_component_parent(self):
return self.device or self.virtual_machine
def clean(self):
# 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)
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
def parent(self):
return self.device or self.virtual_machine
@ -1970,13 +2047,40 @@ class InterfaceConnection(models.Model):
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
#
@python_2_unicode_compatible
class DeviceBay(models.Model):
class DeviceBay(ComponentModel):
"""
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):
return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def clean(self):
# Validate that the parent Device can have DeviceBays
@ -2025,7 +2132,7 @@ class DeviceBay(models.Model):
#
@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.
InventoryItems are used only for inventory purposes.
@ -2094,6 +2201,9 @@ class InventoryItem(models.Model):
def get_absolute_url(self):
return self.device.get_absolute_url()
def get_component_parent(self):
return self.device
def to_csv(self):
return (
self.device.name or '{' + self.device.pk + '}',
@ -2112,7 +2222,7 @@ class InventoryItem(models.Model):
#
@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).
"""
@ -2126,6 +2236,8 @@ class VirtualChassis(models.Model):
blank=True
)
serializer = 'dcim.api.serializers.VirtualChassisSerializer'
class Meta:
ordering = ['master']
verbose_name_plural = 'virtual chassis'

View File

@ -41,12 +41,18 @@ DEVICE_LINK = """
"""
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 %}
<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 %}
"""
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">
<i class="fa fa-eye"></i>
</a>
@ -58,6 +64,9 @@ RACKGROUP_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 %}
<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 %}
@ -76,20 +85,29 @@ RACK_DEVICE_COUNT = """
"""
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 %}
<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 %}
"""
DEVICEROLE_ACTIONS = """
{% 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>
MANUFACTURER_ACTIONS = """
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<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 %}
"""
MANUFACTURER_ACTIONS = """
{% 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>
DEVICEROLE_ACTIONS = """
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<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 %}
"""
@ -110,6 +128,9 @@ PLATFORM_VM_COUNT = """
"""
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 %}
<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 %}
@ -143,6 +164,9 @@ UTILIZATION_GRAPH = """
"""
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 %}
<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 %}

View File

@ -2,11 +2,14 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ImageAttachmentEditView
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView
from secrets.views import secret_add
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'
urlpatterns = [
@ -17,6 +20,7 @@ urlpatterns = [
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/(?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
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-]+)/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-]+)/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}),
# Rack groups
@ -34,6 +39,7 @@ urlpatterns = [
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/(?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
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/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+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
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/(?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+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
# Racks
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+)/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+)/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<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/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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
# Console port templates
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/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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
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/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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
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+)/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+)/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+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
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/(?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+)/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-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 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 utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
@ -945,6 +945,7 @@ class DeviceInventoryView(View):
return render(request, 'dcim/device_inventory.html', {
'device': device,
'inventory_items': inventory_items,
'active_tab': 'inventory',
})
@ -957,6 +958,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_status.html', {
'device': device,
'active_tab': 'status',
})
@ -975,6 +977,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_lldp_neighbors.html', {
'device': device,
'interfaces': interfaces,
'active_tab': 'lldp-neighbors',
})
@ -987,6 +990,7 @@ class DeviceConfigView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_config.html', {
'device': device,
'active_tab': 'config',
})
@ -1104,7 +1108,6 @@ class ConsolePortConnectView(PermissionRequiredMixin, View):
escape(consoleport.cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
@ -1155,7 +1158,6 @@ class ConsolePortDisconnectView(PermissionRequiredMixin, View):
escape(cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
@ -1244,7 +1246,6 @@ class ConsoleServerPortConnectView(PermissionRequiredMixin, View):
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
@ -1296,7 +1297,6 @@ class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View):
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
@ -1390,7 +1390,6 @@ class PowerPortConnectView(PermissionRequiredMixin, View):
escape(powerport.power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
@ -1441,7 +1440,6 @@ class PowerPortDisconnectView(PermissionRequiredMixin, View):
escape(power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
@ -1529,7 +1527,6 @@ class PowerOutletConnectView(PermissionRequiredMixin, View):
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
@ -1580,7 +1577,6 @@ class PowerOutletDisconnectView(PermissionRequiredMixin, View):
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
@ -1910,7 +1906,6 @@ class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, Vie
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
if '_addanother' in request.POST:
base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
@ -1961,7 +1956,6 @@ class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin,
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
return redirect(self.get_return_url(request, interfaceconnection))
@ -2241,7 +2235,6 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
membership_form.save()
msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, device, msg)
if '_addanother' in request.POST:
return redirect(request.get_full_path())
@ -2296,7 +2289,6 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
messages.success(request, msg)
UserAction.objects.log_edit(request.user, device, msg)
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 utilities.forms import LaxURLField
from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from .models import (
CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction,
Webhook
CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, 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
#

View File

@ -6,11 +6,12 @@ from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site
from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES
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.models import ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction
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()
#
# 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
#

View File

@ -37,6 +37,9 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet)
# Reports
router.register(r'reports', views.ReportViewSet, base_name='report')
# Change logging
router.register(r'object-changes', views.ObjectChangeViewSet)
# Recent activity
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 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 utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
from . import serializers
@ -206,6 +208,19 @@ class ReportViewSet(ViewSet):
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
#

View File

@ -66,6 +66,16 @@ TOPOLOGYMAP_TYPE_CHOICES = (
(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
ACTION_CREATE = 1
ACTION_IMPORT = 2

View File

@ -8,7 +8,7 @@ from taggit.models import Tag
from dcim.models import Site
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):
@ -124,6 +124,26 @@ class TopologyMapFilter(django_filters.FilterSet):
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):
username = django_filters.ModelMultipleChoiceFilter(
name='user__username',

View File

@ -3,12 +3,16 @@ from __future__ import unicode_literals
from collections import OrderedDict
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from taggit.models import Tag
from utilities.forms import 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 .models import CustomField, CustomFieldValue, ImageAttachment
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,
OBJECTCHANGE_ACTION_CHOICES,
)
from .models import CustomField, CustomFieldValue, ImageAttachment, ObjectChange
#
@ -189,3 +193,38 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ImageAttachment
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 datetime import date
import json
import graphviz
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.urls import reverse
from django.core.validators import ValidationError
from django.db import models
from django.db.models import Q
@ -656,6 +658,119 @@ class ReportResult(models.Model):
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
#

View File

@ -4,6 +4,7 @@ import django_tables2 as tables
from taggit.models import Tag
from utilities.tables import BaseTable, ToggleColumn
from .models import ObjectChange
TAG_ACTIONS = """
{% if perms.taggit.change_tag %}
@ -14,6 +15,24 @@ TAG_ACTIONS = """
{% 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):
pk = ToggleColumn()
@ -26,3 +45,24 @@ class TagTable(BaseTable):
class Meta(BaseTable.Meta):
model = Tag
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>[^/]+\.[^/]+)/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 django import template
from django.contrib import messages
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.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.safestring import mark_safe
@ -11,10 +13,11 @@ from taggit.models import Tag
from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from .forms import ImageAttachmentForm, TagForm
from .models import ImageAttachment, ReportResult, UserAction
from . import filters
from .forms import ObjectChangeFilterForm, ImageAttachmentForm, TagForm
from .models import ImageAttachment, ObjectChange, ReportResult
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'
#
# 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
#
@ -149,6 +223,5 @@ class ReportRunView(PermissionRequiredMixin, View):
result = 'failed' if report.failed else 'passed'
msg = "Ran report {} ({})".format(report.full_name, result)
messages.success(request, mark_safe(msg))
UserAction.objects.log_create(request.user, report.result, msg)
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 extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel
from utilities.models import ChangeLoggedModel
from .constants import *
from .fields import IPNetworkField, IPAddressField
from .querysets import PrefixQuerySet
@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
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()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
serializer = 'ipam.api.serializers.VRFSerializer'
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta:
ordering = ['name', 'rd']
@ -91,7 +90,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
@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
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'
)
serializer = 'ipam.api.serializers.RIRSerializer'
csv_headers = ['name', 'slug', 'is_private']
class Meta:
@ -131,7 +131,7 @@ class RIR(models.Model):
@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
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()
csv_headers = ['prefix', 'rir', 'date_added', 'description']
serializer = 'ipam.api.serializers.AggregateSerializer'
csv_headers = ['prefix', 'rir', 'date_added', 'description']
class Meta:
ordering = ['family', 'prefix']
@ -228,7 +227,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
@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
"Management."
@ -244,6 +243,7 @@ class Role(models.Model):
default=1000
)
serializer = 'ipam.api.serializers.RoleSerializer'
csv_headers = ['name', 'slug', 'weight']
class Meta:
@ -261,7 +261,7 @@ class Role(models.Model):
@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
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()
tags = TaggableManager()
serializer = 'ipam.api.serializers.PrefixSerializer'
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
]
serializer = 'ipam.api.serializers.PrefixSerializer'
class Meta:
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
@ -503,7 +502,7 @@ class IPAddressManager(models.Manager):
@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
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()
tags = TaggableManager()
serializer = 'ipam.api.serializers.IPAddressSerializer'
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
'description',
]
serializer = 'ipam.api.serializers.IPAddressSerializer'
class Meta:
ordering = ['family', 'address']
verbose_name = 'IP address'
@ -663,7 +661,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
@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.
"""
@ -679,9 +677,8 @@ class VLANGroup(models.Model):
null=True
)
csv_headers = ['name', 'slug', 'site']
serializer = 'ipam.api.serializers.VLANGroupSerializer'
csv_headers = ['name', 'slug', 'site']
class Meta:
ordering = ['site', 'name']
@ -717,7 +714,7 @@ class VLANGroup(models.Model):
@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
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()
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
serializer = 'ipam.api.serializers.VLANSerializer'
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
class Meta:
ordering = ['site', 'group', 'vid']
@ -835,7 +831,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@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
optionally be tied to one or more specific IPAddresses belonging to its parent.

View File

@ -28,6 +28,9 @@ RIR_UTILIZATION = """
"""
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 %}
<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 %}
@ -47,6 +50,9 @@ ROLE_VLAN_COUNT = """
"""
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 %}
<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 %}
@ -127,6 +133,9 @@ VLAN_ROLE_LINK = """
"""
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 %}
{% 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">

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
# RIRs
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/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'^vrfs/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
# Aggregates
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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
# Roles
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/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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
# Prefixes
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+)/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+)/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+)/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/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/(?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/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
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/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+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
# VLANs
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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
# Services
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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
]

View File

@ -522,6 +522,7 @@ class PrefixPrefixesView(View):
'prefix_table': prefix_table,
'permissions': permissions,
'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,
'permissions': permissions,
'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_table = tables.VLANMemberTable(members)
# if request.user.has_perm('dcim.change_interface'):
# members_table.columns.show('pk')
paginate = {
'klass': EnhancedPaginator,
@ -868,18 +868,10 @@ class VLANMembersView(View):
}
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', {
'vlan': vlan,
'members_table': members_table,
# 'permissions': permissions,
# 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'members',
})

View File

@ -50,6 +50,9 @@ BANNER_LOGIN = ''
# BASE_PATH = 'netbox/'
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
# 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

View File

@ -44,6 +44,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
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_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
@ -174,6 +175,7 @@ MIDDLEWARE = (
'utilities.middleware.ExceptionHandlingMiddleware',
'utilities.middleware.LoginRequiredMiddleware',
'utilities.middleware.APIVersionMiddleware',
'extras.middleware.ChangeLoggingMiddleware',
)
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 taggit.managers import TaggableManager
from utilities.models import CreatedUpdatedModel
from utilities.models import ChangeLoggedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
from .querysets import UserKeyQuerySet
@ -48,12 +48,18 @@ def decrypt_master_key(master_key_cipher, private_key):
@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
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.
"""
created = models.DateField(
auto_now_add=True
)
last_updated = models.DateTimeField(
auto_now=True
)
user = models.OneToOneField(
to=User,
on_delete=models.CASCADE,
@ -251,7 +257,7 @@ class SessionKey(models.Model):
@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
such as "Login Credentials" or "SNMP Communities."
@ -277,6 +283,7 @@ class SecretRole(models.Model):
blank=True
)
serializer = 'ipam.api.secrets.SecretSerializer'
csv_headers = ['name', 'slug']
class Meta:
@ -304,7 +311,7 @@ class SecretRole(models.Model):
@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
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()
plaintext = None
serializer = 'ipam.api.secrets.SecretSerializer'
csv_headers = ['device', 'role', 'name', 'plaintext']
class Meta:

View File

@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import SecretRole, Secret
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 %}
<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 %}

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views
from .models import Secret, SecretRole
app_name = 'secrets'
urlpatterns = [
@ -13,6 +15,7 @@ urlpatterns = [
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/(?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
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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
]

View File

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

View File

@ -1,8 +1,10 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -22,8 +24,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -36,9 +38,20 @@
Delete this circuit
</a>
{% endif %}
</div>
<h1>{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=circuit %}
</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 %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">

View File

@ -2,8 +2,10 @@
{% load static from staticfiles %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -22,8 +24,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -42,9 +44,20 @@
Delete this provider
</a>
{% endif %}
</div>
<h1>{% block title %}{{ provider }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=provider %}
</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 %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">

View File

@ -4,9 +4,79 @@
{% block title %}{{ device }}{% endblock %}
{% block header %}
<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">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'dcim:device' pk=device.pk %}">Device</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 %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %}
{% include 'dcim/inc/device_header.html' with active_tab='info' %}
<div class="row">
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
@ -378,8 +448,8 @@
{% endif %}
</div>
</div>
</div>
<div class="row">
</div>
<div class="row">
<div class="col-md-12">
{% if device_bays or device.device_type.is_parent_device %}
{% if perms.dcim.delete_devicebay %}
@ -626,7 +696,7 @@
{% endif %}
{% endif %}
</div>
</div>
</div>
{% include 'inc/graphs_modal.html' %}
{% include 'secrets/inc/private_key_modal.html' %}
{% endblock %}

View File

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

View File

@ -1,10 +1,9 @@
{% extends '_base.html' %}
{% extends 'dcim/device.html' %}
{% block title %}{{ device }} - Inventory{% endblock %}
{% 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="panel panel-default">
<div class="panel-heading">
@ -73,5 +72,5 @@
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

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

View File

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

View File

@ -1,8 +1,10 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -10,8 +12,8 @@
<li>{{ devicetype.model }}</li>
</ol>
</div>
</div>
{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
</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">
@ -26,9 +28,20 @@
</a>
{% endif %}
</div>
{% endif %}
<h1>{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=devicetype %}
{% 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 %}
<div class="row">
<div class="col-md-5">
<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,8 +1,8 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -22,8 +22,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -40,9 +40,20 @@
<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>
<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 %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">

View File

@ -3,10 +3,11 @@
{% load tz %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -28,8 +29,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -48,9 +49,20 @@
Delete this site
</a>
{% endif %}
</div>
<h1>{% block title %}{{ site }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=site %}
</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 %}
<div class="row">
<div class="col-md-7">
<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>
<a href="{% url 'extras:report_list' %}">Reports</a>
</li>
<li>
<a href="{% url 'extras:objectchange_list' %}">Changelog</a>
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">

View File

@ -1,8 +1,8 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -22,8 +22,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -36,9 +36,20 @@
Delete this aggregate
</a>
{% endif %}
</div>
<h1>{% block title %}{{ aggregate }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=aggregate %}
</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 %}
<div class="row">
<div class="col-md-6">
<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>{{ service.description }}</td>
<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 %}
<a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
<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,8 +1,8 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -24,8 +24,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -38,9 +38,20 @@
Delete this IP
</a>
{% endif %}
</div>
<h1>{% block title %}{{ ipaddress }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=ipaddress %}
</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 %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">

View File

@ -1,11 +1,75 @@
{% extends '_base.html' %}
{% 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 %}
{% include 'ipam/inc/prefix_header.html' with active_tab='prefix' %}
<div class="row">
<div class="row">
<div class="col-md-5">
<div class="panel panel-default">
<div class="panel-heading">
@ -148,5 +212,5 @@
{% endif %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
</div>
</div>
</div>
{% 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 %}
{% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %}
<div class="row">
<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' %}

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 %}
{% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
<div class="row">
<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 %}

View File

@ -1,9 +1,64 @@
{% extends '_base.html' %}
{% load helpers %}
{% block header %}
<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 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 %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
<div class="row">
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
@ -114,5 +169,5 @@
{% endif %}
</div>
</div>
</div>
</div>
{% 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 %}
{% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}

View File

@ -1,8 +1,8 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -21,8 +21,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -35,9 +35,20 @@
Delete this VRF
</a>
{% endif %}
</div>
<h1>{% block title %}VRF {{ vrf }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vrf %}
</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 %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">

View File

@ -3,8 +3,8 @@
{% load helpers %}
{% load secret_helpers %}
{% block content %}
<div class="row">
{% block header %}
<div class="row">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li>
@ -12,8 +12,8 @@
<li>{{ secret.device }}{% if secret.name %} ({{ secret.name }}){% endif %}</li>
</ol>
</div>
</div>
<div class="pull-right">
</div>
<div class="pull-right">
{% if perms.secrets.change_secret %}
<a href="{% url 'secrets:secret_edit' pk=secret.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
@ -26,9 +26,20 @@
Delete this secret
</a>
{% endif %}
</div>
<h1>{% block title %}{{ secret }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=secret %}
</div>
<h1>{% block title %}{{ secret }}{% endblock %}</h1>
{% 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="col-md-6">
<div class="panel panel-default">

View File

@ -1,8 +1,8 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% 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>
@ -24,8 +24,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -38,9 +38,20 @@
Delete this tenant
</a>
{% endif %}
</div>
<h1>{% block title %}{{ tenant }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=tenant %}
</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 %}
<div class="row">
<div class="col-md-7">
<div class="panel panel-default">

View File

@ -1,8 +1,8 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row" xmlns="http://www.w3.org/1999/html">
{% 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>
@ -24,8 +24,8 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</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>
@ -38,9 +38,20 @@
Delete this cluster
</a>
{% endif %}
</div>
<h1>{% block title %}{{ cluster }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=cluster %}
</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 %}
<div class="row">
<div class="col-md-5">
<div class="panel panel-default">

View File

@ -1,14 +1,14 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
{% block header %}
<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>
{% if virtualmachine.cluster %}
<li><a href="{{ virtualmachine.cluster.get_absolute_url }}">{{ virtualmachine.cluster }}</a></li>
{% endif %}
<li>{{ vm }}</li>
<li>{{ virtualmachine }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
@ -23,23 +23,34 @@
</div>
</form>
</div>
</div>
<div class="pull-right">
</div>
<div class="pull-right">
{% if perms.virtualization.change_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_edit' pk=vm.pk %}" class="btn btn-warning">
<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=vm.pk %}" class="btn btn-danger">
<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 %}{{ vm }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=vm %}
</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 %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
@ -49,19 +60,19 @@
<table class="table table-hover panel-body attr-table">
<tr>
<td>Name</td>
<td>{{ vm }}</td>
<td>{{ virtualmachine }}</td>
</tr>
<tr>
<td>Status</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>
</tr>
<tr>
<td>Role</td>
<td>
{% if vm.role %}
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ vm.role.slug }}">{{ vm.role }}</a>
{% if virtualmachine.role %}
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ virtualmachine.role.slug }}">{{ virtualmachine.role }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
@ -70,8 +81,8 @@
<tr>
<td>Platform</td>
<td>
{% if vm.platform %}
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ vm.platform.slug }}">{{ vm.platform }}</a>
{% if virtualmachine.platform %}
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ virtualmachine.platform.slug }}">{{ virtualmachine.platform }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
@ -80,12 +91,12 @@
<tr>
<td>Tenant</td>
<td>
{% if vm.tenant %}
{% if vm.tenant.group %}
<a href="{{ vm.tenant.group.get_absolute_url }}">{{ vm.tenant.group }}</a>
{% if virtualmachine.tenant %}
{% if virtualmachine.tenant.group %}
<a href="{{ virtualmachine.tenant.group.get_absolute_url }}">{{ virtualmachine.tenant.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ vm.tenant.get_absolute_url }}">{{ vm.tenant }}</a>
<a href="{{ virtualmachine.tenant.get_absolute_url }}">{{ virtualmachine.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
@ -94,12 +105,12 @@
<tr>
<td>Primary IPv4</td>
<td>
{% if vm.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip4.pk %}">{{ vm.primary_ip4.address.ip }}</a>
{% if vm.primary_ip4.nat_inside %}
<span>(NAT for {{ vm.primary_ip4.nat_inside.address.ip }})</span>
{% elif vm.primary_ip4.nat_outside %}
<span>(NAT: {{ vm.primary_ip4.nat_outside.address.ip }})</span>
{% if virtualmachine.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=virtualmachine.primary_ip4.pk %}">{{ virtualmachine.primary_ip4.address.ip }}</a>
{% if virtualmachine.primary_ip4.nat_inside %}
<span>(NAT for {{ virtualmachine.primary_ip4.nat_inside.address.ip }})</span>
{% elif virtualmachine.primary_ip4.nat_outside %}
<span>(NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
@ -109,12 +120,12 @@
<tr>
<td>Primary IPv6</td>
<td>
{% if vm.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=vm.primary_ip6.pk %}">{{ vm.primary_ip6.address.ip }}</a>
{% if vm.primary_ip6.nat_inside %}
<span>(NAT for {{ vm.primary_ip6.nat_inside.address.ip }})</span>
{% elif vm.primary_ip6.nat_outside %}
<span>(NAT: {{ vm.primary_ip6.nat_outside.address.ip }})</span>
{% if virtualmachine.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=virtualmachine.primary_ip6.pk %}">{{ virtualmachine.primary_ip6.address.ip }}</a>
{% if virtualmachine.primary_ip6.nat_inside %}
<span>(NAT for {{ virtualmachine.primary_ip6.nat_inside.address.ip }})</span>
{% elif virtualmachine.primary_ip6.nat_outside %}
<span>(NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
@ -124,7 +135,7 @@
<tr>
<td>Tags</td>
<td>
{% for tag in vm.tags.all %}
{% for tag in virtualmachine.tags.all %}
{% tag 'virtualization:virtualmachine_list' tag %}
{% empty %}
<span class="text-muted">N/A</span>
@ -133,14 +144,14 @@
</tr>
</table>
</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-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
{% if vm.comments %}
{{ vm.comments|gfm }}
{% if virtualmachine.comments %}
{{ virtualmachine.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
@ -156,16 +167,16 @@
<tr>
<td>Cluster</td>
<td>
{% if vm.cluster.group %}
<a href="{{ vm.cluster.group.get_absolute_url }}">{{ vm.cluster.group }}</a>
{% if virtualmachine.cluster.group %}
<a href="{{ virtualmachine.cluster.group.get_absolute_url }}">{{ virtualmachine.cluster.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a>
<a href="{{ virtualmachine.cluster.get_absolute_url }}">{{ virtualmachine.cluster }}</a>
</td>
</tr>
<tr>
<td>Cluster Type</td>
<td>{{ vm.cluster.type }}</td>
<td>{{ virtualmachine.cluster.type }}</td>
</tr>
</table>
</div>
@ -177,8 +188,8 @@
<tr>
<td><i class="fa fa-tachometer"></i> Virtual CPUs</td>
<td>
{% if vm.vcpus %}
{{ vm.vcpus }}
{% if virtualmachine.vcpus %}
{{ virtualmachine.vcpus }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
@ -187,8 +198,8 @@
<tr>
<td><i class="fa fa-microchip"></i> Memory</td>
<td>
{% if vm.memory %}
{{ vm.memory }} MB
{% if virtualmachine.memory %}
{{ virtualmachine.memory }} MB
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
@ -197,8 +208,8 @@
<tr>
<td><i class="fa fa-hdd-o"></i> Disk Space</td>
<td>
{% if vm.disk %}
{{ vm.disk }} GB
{% if virtualmachine.disk %}
{{ virtualmachine.disk }} GB
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
@ -223,7 +234,7 @@
{% endif %}
{% if perms.ipam.add_service %}
<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
</a>
</div>
@ -236,7 +247,7 @@
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="virtual_machine" value="{{ vm.pk }}" />
<input type="hidden" name="virtual_machine" value="{{ virtualmachine.pk }}" />
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
@ -264,7 +275,7 @@
</thead>
<tbody>
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' with device=vm %}
{% include 'dcim/inc/interface.html' with device=virtualmachine %}
{% empty %}
<tr>
<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 %}
<div class="panel-footer">
{% 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
</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
</button>
{% endif %}
{% 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
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<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
</a>
</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 extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel
from utilities.models import ChangeLoggedModel
@python_2_unicode_compatible
class TenantGroup(models.Model):
class TenantGroup(ChangeLoggedModel):
"""
An arbitrary collection of Tenants.
"""
@ -23,9 +23,8 @@ class TenantGroup(models.Model):
unique=True
)
csv_headers = ['name', 'slug']
serializer = 'tenancy.api.serializers.TenantGroupSerializer'
csv_headers = ['name', 'slug']
class Meta:
ordering = ['name']
@ -44,7 +43,7 @@ class TenantGroup(models.Model):
@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
department.
@ -79,9 +78,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager()
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
serializer = 'tenancy.api.serializers.TenantSerializer'
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
class Meta:
ordering = ['group', 'name']

View File

@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Tenant, TenantGroup
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 %}
<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 %}

View File

@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views
from .models import Tenant, TenantGroup
app_name = 'tenancy'
urlpatterns = [
@ -13,6 +15,7 @@ urlpatterns = [
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/(?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
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-]+)/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-]+)/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.viewsets import GenericViewSet, ViewSet
from .utils import dynamic_import
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@ -24,6 +26,20 @@ class ServiceUnavailable(APIException):
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
#

View File

@ -2,10 +2,38 @@ from __future__ import unicode_literals
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)
last_updated = models.DateTimeField(auto_now=True)
class ChangeLoggedModel(models.Model):
"""
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:
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
import datetime
import json
import six
from django.core.serializers import serialize
from django.http import HttpResponse
@ -82,3 +84,15 @@ def dynamic_import(name):
for comp in components[1:]:
mod = getattr(mod, comp)
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_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 utilities.utils import queryset_to_csv
from utilities.forms import BootstrapMixin, CSVDataField
@ -213,11 +213,6 @@ class ObjectEditView(GetReturnURLMixin, View):
msg = '{} {}'.format(msg, escape(obj))
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:
return redirect(request.get_full_path())
@ -279,7 +274,6 @@ class ObjectDeleteView(GetReturnURLMixin, View):
msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
messages.success(request, msg)
UserAction.objects.log_delete(request.user, obj, msg)
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()):
@ -365,7 +359,6 @@ class BulkCreateView(View):
# 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)
messages.success(request, msg)
UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
if '_addanother' in request.POST:
return redirect(request.path)
@ -450,7 +443,6 @@ class BulkImportView(View):
if new_objs:
msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
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", {
'table': obj_table,
@ -566,7 +558,6 @@ class BulkEditView(View):
if updated_count:
msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
messages.success(self.request, msg)
UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
return redirect(return_url)
@ -661,7 +652,6 @@ class BulkDeleteView(View):
msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
messages.success(request, msg)
UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
return redirect(return_url)
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 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
@ -19,7 +19,7 @@ from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSE
#
@python_2_unicode_compatible
class ClusterType(models.Model):
class ClusterType(ChangeLoggedModel):
"""
A type of Cluster.
"""
@ -31,6 +31,7 @@ class ClusterType(models.Model):
unique=True
)
serializer = 'virtualization.api.serializers.ClusterTypeSerializer'
csv_headers = ['name', 'slug']
class Meta:
@ -54,7 +55,7 @@ class ClusterType(models.Model):
#
@python_2_unicode_compatible
class ClusterGroup(models.Model):
class ClusterGroup(ChangeLoggedModel):
"""
An organizational group of Clusters.
"""
@ -66,9 +67,8 @@ class ClusterGroup(models.Model):
unique=True
)
csv_headers = ['name', 'slug']
serializer = 'virtualization.api.serializers.ClusterGroupSerializer'
csv_headers = ['name', 'slug']
class Meta:
ordering = ['name']
@ -91,7 +91,7 @@ class ClusterGroup(models.Model):
#
@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.
"""
@ -129,9 +129,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager()
csv_headers = ['name', 'type', 'group', 'site', 'comments']
serializer = 'virtualization.api.serializers.ClusterSerializer'
csv_headers = ['name', 'type', 'group', 'site', 'comments']
class Meta:
ordering = ['name']
@ -169,7 +168,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
#
@python_2_unicode_compatible
class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
class VirtualMachine(ChangeLoggedModel, CustomFieldModel):
"""
A virtual machine which runs inside a Cluster.
"""
@ -251,12 +250,11 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
tags = TaggableManager()
serializer = 'virtualization.api.serializers.VirtualMachineSerializer'
csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
]
serializer = 'virtualization.api.serializers.VirtualMachineSerializer'
class Meta:
ordering = ['name']

View File

@ -9,12 +9,18 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
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 %}
<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 %}
"""
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 %}
<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 %}

View File

@ -2,8 +2,10 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from ipam.views import ServiceCreateView
from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
app_name = 'virtualization'
urlpatterns = [
@ -14,6 +16,7 @@ urlpatterns = [
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/(?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
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/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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
# Clusters
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+)/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+)/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/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+)/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+)/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'),
# VM interfaces

View File

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