Merge branch 'develop-2.6' of github.com:digitalocean/netbox into develop-2.6

This commit is contained in:
John Anderson 2019-03-03 19:06:05 -05:00
commit 5991bd368c
47 changed files with 810 additions and 137 deletions

View File

@ -1,3 +1,30 @@
v2.6.0 (FUTURE)
## Changes
### API Device/VM Config Context Included by Default ([#2350](https://github.com/digitalocean/netbox/issues/2350))
The rendered Config Context for Devices and VMs is now included by default in all API results (list and detail views).
Previously the rendered Config Context was only available in the detail view for objects. Users with large amounts of
context data may observe a performance drop when returning multiple objects. To combat this, in cases where the rendered
Config Context is not needed, the query parameter `?exclude=config_context` may be added to the request as to remove
the Config Context from being included in any results.
### Tag Permissions Changed
NetBox now makes use of its own `Tag` model instead of the vanilla model which ships with django-taggit. This new model
lives in the `extras` app and thus any permissions that you may have configured using "Taggit | Tag" should be changed
to now use "Extras | Tag."
## Enhancements
* [#2324](https://github.com/digitalocean/netbox/issues/2324) - Add color option for tags
* [#2643](https://github.com/digitalocean/netbox/issues/2643) - Add `description` field to console/power components and device bays
* [#2791](https://github.com/digitalocean/netbox/issues/2791) - Add a comment field for tags
* [#2926](https://github.com/digitalocean/netbox/issues/2926) - Add changelog to the Tag model
---
v2.5.8 (FUTURE)
## Bug Fixes

View File

@ -0,0 +1,25 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('circuits', '0014_circuittermination_description'),
('extras', '0017_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='circuit',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='provider',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -6,7 +6,7 @@ from taggit.managers import TaggableManager
from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
from dcim.fields import ASNField
from dcim.models import CableTermination
from extras.models import CustomFieldModel, ObjectChange
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
@ -55,7 +55,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
@ -165,7 +165,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',

View File

@ -346,8 +346,8 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
]
@ -359,8 +359,8 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = ConsolePort
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
]
@ -372,8 +372,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = PowerOutlet
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
]
@ -385,8 +385,8 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = PowerPort
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
]
@ -475,7 +475,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'installed_device', 'tags']
fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
#

View File

@ -291,16 +291,23 @@ class DeviceViewSet(CustomFieldModelViewSet):
def get_serializer_class(self):
"""
Include rendered config context when retrieving a single Device.
Select the specific serializer based on the request context.
If the `brief` query param equates to True, return the NestedDeviceSerializer
If the `exclude` query param includes `config_context` as a value, return the DeviceSerializer
Else, return the DeviceWithConfigContextSerializer
"""
if self.action == 'retrieve':
return serializers.DeviceWithConfigContextSerializer
request = self.get_serializer_context()['request']
if request.query_params.get('brief', False):
return serializers.NestedDeviceSerializer
return serializers.DeviceSerializer
elif 'config_context' in request.query_params.get('exclude', []):
return serializers.DeviceSerializer
return serializers.DeviceWithConfigContextSerializer
@action(detail=True, url_path='napalm')
def napalm(self, request, pk):

View File

@ -693,7 +693,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
Q(name__icontains=value) |
Q(description__icontains=value)
)
@ -706,7 +707,7 @@ class ConsolePortFilter(DeviceComponentFilterSet):
class Meta:
model = ConsolePort
fields = ['name', 'connection_status']
fields = ['name', 'description', 'connection_status']
class ConsoleServerPortFilter(DeviceComponentFilterSet):
@ -718,7 +719,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet):
class Meta:
model = ConsoleServerPort
fields = ['name', 'connection_status']
fields = ['name', 'description', 'connection_status']
class PowerPortFilter(DeviceComponentFilterSet):
@ -730,7 +731,7 @@ class PowerPortFilter(DeviceComponentFilterSet):
class Meta:
model = PowerPort
fields = ['name', 'connection_status']
fields = ['name', 'description', 'connection_status']
class PowerOutletFilter(DeviceComponentFilterSet):
@ -742,7 +743,7 @@ class PowerOutletFilter(DeviceComponentFilterSet):
class Meta:
model = PowerOutlet
fields = ['name', 'connection_status']
fields = ['name', 'description', 'connection_status']
class InterfaceFilter(django_filters.FilterSet):
@ -797,13 +798,14 @@ class InterfaceFilter(django_filters.FilterSet):
class Meta:
model = Interface
fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
Q(name__icontains=value) |
Q(description__icontains=value)
).distinct()
def filter_device(self, queryset, name, value):
@ -861,7 +863,7 @@ class FrontPortFilter(DeviceComponentFilterSet):
class Meta:
model = FrontPort
fields = ['name', 'type']
fields = ['name', 'type', 'description']
class RearPortFilter(DeviceComponentFilterSet):
@ -873,14 +875,14 @@ class RearPortFilter(DeviceComponentFilterSet):
class Meta:
model = RearPort
fields = ['name', 'type']
fields = ['name', 'type', 'description']
class DeviceBayFilter(DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ['name']
fields = ['name', 'description']
class InventoryItemFilter(DeviceComponentFilterSet):

View File

@ -1854,7 +1854,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePort
fields = [
'device', 'name', 'tags',
'device', 'name', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -1865,6 +1865,10 @@ class ConsolePortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
description = forms.CharField(
max_length=100,
required=False
)
tags = TagField(
required=False
)
@ -1882,7 +1886,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPort
fields = [
'device', 'name', 'tags',
'device', 'name', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -1893,11 +1897,31 @@ class ConsoleServerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
description = forms.CharField(
max_length=100,
required=False
)
tags = TagField(
required=False
)
class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConsoleServerPort.objects.all(),
widget=forms.MultipleHiddenInput()
)
description = forms.CharField(
max_length=100,
required=False
)
class Meta:
nullable_fields = [
'description',
]
class ConsoleServerPortBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConsoleServerPort.objects.all(),
@ -1924,7 +1948,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPort
fields = [
'device', 'name', 'tags',
'device', 'name', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -1935,6 +1959,10 @@ class PowerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
description = forms.CharField(
max_length=100,
required=False
)
tags = TagField(
required=False
)
@ -1952,7 +1980,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutlet
fields = [
'device', 'name', 'tags',
'device', 'name', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -1963,11 +1991,31 @@ class PowerOutletCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
description = forms.CharField(
max_length=100,
required=False
)
tags = TagField(
required=False
)
class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerOutlet.objects.all(),
widget=forms.MultipleHiddenInput()
)
description = forms.CharField(
max_length=100,
required=False
)
class Meta:
nullable_fields = [
'description',
]
class PowerOutletBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerOutlet.objects.all(),
@ -2781,7 +2829,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = DeviceBay
fields = [
'device', 'name', 'tags',
'device', 'name', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),

View File

@ -0,0 +1,85 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('dcim', '0069_deprecate_nullablecharfield'),
('extras', '0017_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='consoleport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='consoleserverport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='device',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='devicebay',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='devicetype',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='frontport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='interface',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='inventoryitem',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='poweroutlet',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='powerport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='rack',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='rearport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='site',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='virtualchassis',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 2.1.7 on 2019-02-20 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0070_custom_tag_models'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='consoleserverport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='devicebay',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='poweroutlet',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='powerport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@ -15,7 +15,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from utilities.fields import ColorField
from utilities.managers import NaturalOrderingManager
from utilities.models import ChangeLoggedModel
@ -46,6 +46,10 @@ class ComponentTemplateModel(models.Model):
class ComponentModel(models.Model):
description = models.CharField(
max_length=100,
blank=True
)
class Meta:
abstract = True
@ -319,7 +323,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
)
objects = NaturalOrderingManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@ -566,7 +570,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
)
objects = NaturalOrderingManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
@ -914,7 +918,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
@ -1455,7 +1459,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
objects = NaturalOrderingManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
@ -1743,9 +1747,9 @@ class ConsolePort(CableTermination, ComponentModel):
)
objects = DeviceComponentManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name']
csv_headers = ['device', 'name', 'description']
class Meta:
ordering = ['device', 'name']
@ -1761,6 +1765,7 @@ class ConsolePort(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.description,
)
@ -1786,9 +1791,9 @@ class ConsoleServerPort(CableTermination, ComponentModel):
)
objects = DeviceComponentManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name']
csv_headers = ['device', 'name', 'description']
class Meta:
unique_together = ['device', 'name']
@ -1803,6 +1808,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.description,
)
@ -1835,13 +1841,13 @@ class PowerPort(CableTermination, ComponentModel):
)
objects = DeviceComponentManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
unique_together = ['device', 'name', 'description']
def __str__(self):
return self.name
@ -1853,6 +1859,7 @@ class PowerPort(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.description,
)
@ -1878,12 +1885,12 @@ class PowerOutlet(CableTermination, ComponentModel):
)
objects = DeviceComponentManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name']
class Meta:
unique_together = ['device', 'name']
unique_together = ['device', 'name', 'description']
def __str__(self):
return self.name
@ -1895,6 +1902,7 @@ class PowerOutlet(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.description,
)
@ -1973,10 +1981,6 @@ class Interface(CableTermination, ComponentModel):
verbose_name='OOB Management',
help_text='This interface is used only for out-of-band management'
)
description = models.CharField(
max_length=100,
blank=True
)
mode = models.PositiveSmallIntegerField(
choices=IFACE_MODE_CHOICES,
blank=True,
@ -1998,7 +2002,7 @@ class Interface(CableTermination, ComponentModel):
)
objects = InterfaceManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
@ -2193,13 +2197,9 @@ class FrontPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
description = models.CharField(
max_length=100,
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
@ -2259,13 +2259,9 @@ class RearPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
description = models.CharField(
max_length=100,
blank=True
)
objects = DeviceComponentManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'positions', 'description']
@ -2312,9 +2308,9 @@ class DeviceBay(ComponentModel):
)
objects = DeviceComponentManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'installed_device']
csv_headers = ['device', 'name', 'installed_device', 'description']
class Meta:
ordering = ['device', 'name']
@ -2331,6 +2327,7 @@ class DeviceBay(ComponentModel):
self.device.identifier,
self.name,
self.installed_device.identifier if self.installed_device else None,
self.description,
)
def clean(self):
@ -2400,12 +2397,8 @@ class InventoryItem(ComponentModel):
default=False,
verbose_name='Discovered'
)
description = models.CharField(
max_length=100,
blank=True
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
@ -2452,7 +2445,7 @@ class VirtualChassis(ChangeLoggedModel):
blank=True
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['master', 'domain']

View File

@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase):
site=self.site1,
cluster=self.cluster1
)
self.device_with_context_data = Device.objects.create(
device_type=self.devicetype1,
device_role=self.devicerole1,
name='Device with context data',
site=self.site1,
local_context_data={
'A': 1,
'B': 2
}
)
def test_get_device(self):
@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase):
url = reverse('dcim-api:device-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
self.assertEqual(response.data['count'], 4)
def test_list_devices_brief(self):
@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Device.objects.count(), 4)
self.assertEqual(Device.objects.count(), 5)
device4 = Device.objects.get(pk=response.data['id'])
self.assertEqual(device4.device_type_id, data['device_type'])
self.assertEqual(device4.device_role_id, data['device_role'])
@ -1867,7 +1877,7 @@ class DeviceTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Device.objects.count(), 6)
self.assertEqual(Device.objects.count(), 7)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
@ -1891,7 +1901,7 @@ class DeviceTest(APITestCase):
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Device.objects.count(), 3)
self.assertEqual(Device.objects.count(), 4)
device1 = Device.objects.get(pk=response.data['id'])
self.assertEqual(device1.device_type_id, data['device_type'])
self.assertEqual(device1.device_role_id, data['device_role'])
@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Device.objects.count(), 2)
self.assertEqual(Device.objects.count(), 3)
def test_config_context_included_by_default_in_list_view(self):
url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
response = self.client.get(url, **self.header)
self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
def test_config_context_excluded(self):
url = reverse('dcim-api:device-list') + '?exclude=config_context'
response = self.client.get(url, **self.header)
self.assertFalse('config_context' in response.data['results'][0])
class ConsolePortTest(APITestCase):

View File

@ -1,6 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from taggit.models import Tag
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
@ -10,6 +9,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.constants import *
from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
Tag
)
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
@ -80,7 +80,7 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = ['id', 'name', 'slug', 'tagged_items']
fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items']
#

View File

@ -6,11 +6,11 @@ from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from taggit.models import Tag
from extras import filters
from extras.models import (
ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
Tag
)
from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@ -115,7 +115,7 @@ class TopologyMapViewSet(ModelViewSet):
#
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
queryset = Tag.objects.annotate(tagged_items=Count('extras_taggeditem_items'))
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilter

View File

@ -1,12 +1,11 @@
import django_filters
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
class CustomFieldFilter(django_filters.Filter):

View File

@ -6,19 +6,18 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, CommentField
)
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 ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
#
@ -190,11 +189,12 @@ class CustomFieldFilterForm(forms.Form):
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
comments = CommentField()
class Meta:
model = Tag
fields = [
'name', 'slug',
'name', 'slug', 'color', 'comments'
]

View File

@ -0,0 +1,43 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0016_exporttemplate_add_cable'),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TaggedItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('object_id', models.IntegerField(db_index=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_tagged_items', to='contenttypes.ContentType')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_items', to='extras.Tag')),
],
options={
'abstract': False,
},
),
migrations.AlterIndexTogether(
name='taggeditem',
index_together={('content_type', 'object_id')},
),
]

View File

@ -0,0 +1,65 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
def copy_tags(apps, schema_editor):
"""
Copy data from taggit_tag to extras_tag
"""
TaggitTag = apps.get_model('taggit', 'Tag')
ExtrasTag = apps.get_model('extras', 'Tag')
tags_values = TaggitTag.objects.all().values('id', 'name', 'slug')
tags = [ExtrasTag(**tag) for tag in tags_values]
ExtrasTag.objects.bulk_create(tags)
def copy_taggeditems(apps, schema_editor):
"""
Copy data from taggit_taggeditem to extras_taggeditem
"""
TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
ExtrasTaggedItem = apps.get_model('extras', 'TaggedItem')
tagged_items_values = TaggitTaggedItem.objects.all().values('id', 'object_id', 'content_type_id', 'tag_id')
tagged_items = [ExtrasTaggedItem(**tagged_item) for tagged_item in tagged_items_values]
ExtrasTaggedItem.objects.bulk_create(tagged_items)
def delete_taggit_taggeditems(apps, schema_editor):
"""
Delete all TaggedItem instances from taggit_taggeditem
"""
TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
TaggitTaggedItem.objects.all().delete()
def delete_taggit_tags(apps, schema_editor):
"""
Delete all Tag instances from taggit_tag
"""
TaggitTag = apps.get_model('taggit', 'Tag')
TaggitTag.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('extras', '0017_tag_taggeditem'),
('circuits', '0015_custom_tag_models'),
('dcim', '0070_custom_tag_models'),
('ipam', '0025_custom_tag_models'),
('secrets', '0006_custom_tag_models'),
('tenancy', '0006_custom_tag_models'),
('virtualization', '0009_custom_tag_models'),
]
operations = [
migrations.RunPython(copy_tags),
migrations.RunPython(copy_taggeditems),
migrations.RunPython(delete_taggit_taggeditems),
migrations.RunPython(delete_taggit_tags),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 2.1.4 on 2019-02-20 07:38
from django.db import migrations, models
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('extras', '0018_tag_data'),
]
operations = [
migrations.AddField(
model_name='tag',
name='color',
field=utilities.fields.ColorField(max_length=6, default='9e9e9e'),
),
migrations.AddField(
model_name='tag',
name='comments',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='tag',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='tag',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -12,8 +12,10 @@ from django.db.models import F, Q
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from taggit.models import TagBase, GenericTaggedItemBase
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.fields import ColorField
from utilities.utils import deepmerge, foreground_color
from .constants import *
from .querysets import ConfigContextQuerySet
@ -860,3 +862,37 @@ class ObjectChange(models.Model):
self.object_repr,
self.object_data,
)
#
# Tags
#
# TODO: figure out a way around this circular import for ObjectChange
from utilities.models import ChangeLoggedModel # noqa: E402
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
default='9e9e9e'
)
comments = models.TextField(
blank=True,
default=''
)
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(
to=Tag,
related_name="%(app_label)s_%(class)s_items",
on_delete=models.CASCADE
)
class Meta:
index_together = (
("content_type", "object_id")
)

View File

@ -1,11 +1,13 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from taggit.models import Tag, TaggedItem
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
from .models import ConfigContext, ObjectChange
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
TAG_ACTIONS = """
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.taggit.change_tag %}
<a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@ -71,10 +73,11 @@ class TagTable(BaseTable):
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
color = ColorColumn()
class Meta(BaseTable.Meta):
model = Tag
fields = ('pk', 'name', 'items', 'slug', 'actions')
fields = ('pk', 'name', 'items', 'slug', 'color', 'actions')
class TaggedItemTable(BaseTable):

View File

@ -1,11 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from taggit.models import Tag
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
from extras.constants import GRAPH_TYPE_SITE
from extras.models import ConfigContext, Graph, ExportTemplate
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase

View File

@ -4,10 +4,9 @@ import uuid
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
from taggit.models import Tag
from dcim.models import Site
from extras.models import ConfigContext, ObjectChange
from extras.models import ConfigContext, ObjectChange, Tag
class TagTestCase(TestCase):

View File

@ -1,6 +1,8 @@
from django.conf.urls import url
from extras import views
from extras.models import Tag
app_name = 'extras'
urlpatterns = [
@ -11,6 +13,7 @@ urlpatterns = [
url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
url(r'^tags/(?P<slug>[\w-]+)/changelog/$', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
# Config contexts
url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),

View File

@ -9,7 +9,6 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.views.generic import View
from django_tables2 import RequestConfig
from taggit.models import Tag, TaggedItem
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
@ -19,7 +18,7 @@ from .forms import (
ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
TagFilterForm, TagForm,
)
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
@ -30,7 +29,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
class TagListView(ObjectListView):
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
items=Count('extras_taggeditem_items')
).order_by(
'name'
)
@ -69,22 +68,23 @@ class TagView(View):
class TagEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'taggit.change_tag'
permission_required = 'extras.change_tag'
model = Tag
model_form = TagForm
default_return_url = 'extras:tag_list'
template_name = 'extras/tag_edit.html'
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'taggit.delete_tag'
permission_required = 'extras.delete_tag'
model = Tag
default_return_url = 'extras:tag_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'taggit.delete_tag'
permission_required = 'extras.delete_tag'
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
items=Count('extras_taggeditem_items')
).order_by(
'name'
)

View File

@ -0,0 +1,45 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('ipam', '0024_vrf_allow_null_rd'),
('extras', '0017_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='aggregate',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='ipaddress',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='prefix',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='service',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='vlan',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='vrf',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -10,7 +10,7 @@ from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.models import Interface
from extras.models import CustomFieldModel
from extras.models import CustomFieldModel, TaggedItem
from utilities.models import ChangeLoggedModel
from .constants import *
from .fields import IPNetworkField, IPAddressField
@ -55,7 +55,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
@ -154,7 +154,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['prefix', 'rir', 'date_added', 'description']
@ -324,7 +324,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
)
objects = PrefixQuerySet.as_manager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@ -583,7 +583,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
)
objects = IPAddressManager()
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
@ -790,7 +790,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
@ -892,7 +892,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description']

View File

@ -2,8 +2,17 @@ from django.conf import settings
from django.contrib.admin import AdminSite
from django.contrib.auth.admin import GroupAdmin, UserAdmin
from django.contrib.auth.models import Group, User
from taggit.admin import TagAdmin
from taggit.models import Tag
from taggit.admin import TagAdmin, TaggedItemInline
from extras.models import Tag, TaggedItem
class NetBoxTaggedItemInline(TaggedItemInline):
model = TaggedItem
class NetBoxTagAdmin(TagAdmin):
inlines = [NetBoxTaggedItemInline]
class NetBoxAdminSite(AdminSite):
@ -20,7 +29,7 @@ admin_site = NetBoxAdminSite(name='admin')
# Register external models
admin_site.register(Group, GroupAdmin)
admin_site.register(User, UserAdmin)
admin_site.register(Tag, TagAdmin)
admin_site.register(Tag, NetBoxTagAdmin)
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
if settings.WEBHOOKS_ENABLED:

View File

@ -22,7 +22,7 @@ except ImportError:
)
VERSION = '2.5.8-dev'
VERSION = '2.6.0-dev'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -0,0 +1,20 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('secrets', '0005_change_logging'),
('extras', '0017_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='secret',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -14,7 +14,7 @@ from django.urls import reverse
from django.utils.encoding import force_bytes
from taggit.managers import TaggableManager
from extras.models import CustomFieldModel
from extras.models import CustomFieldModel, TaggedItem
from utilities.models import ChangeLoggedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
@ -345,7 +345,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext']

View File

@ -445,6 +445,7 @@
{% endif %}
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th colspan="2">Installed Device</th>
<th></th>
</tr>
@ -570,6 +571,7 @@
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Description</th>
<th>Cable</th>
<th colspan="2">Connection</th>
<th></th>
@ -625,6 +627,7 @@
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Description</th>
<th>Cable</th>
<th colspan="2">Connection</th>
<th></th>

View File

@ -5,6 +5,11 @@
<i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
</td>
{# Description #}
<td>
{{ cp.description }}
</td>
{# Cable #}
<td>
{% if cp.cable %}

View File

@ -1,3 +1,5 @@
{% load helpers %}
<tr class="consoleserverport{% if csp.cable.status %} success{% elif csp.cable %} info{% endif %}">
{# Checkbox #}
@ -12,12 +14,17 @@
<i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
</td>
{# Description #}
<td>
{{ csp.description|placeholder }}
</td>
{# Cable #}
<td>
{% if csp.cable %}
<a href="{{ csp.cable.get_absolute_url }}">{{ csp.cable }}</a>
{% else %}
&mdash;
<span class="text-muted">&mdash;</span>
{% endif %}
</td>

View File

@ -1,16 +1,35 @@
{% load helpers %}
<tr class="devicebay">
{% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
</td>
{% endif %}
{# Name #}
<td>
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
</td>
{# Status #}
<td>
{% if devicebay.installed_device %}
<span class="label label-{{ devicebay.installed_device.get_status_class }}">
{{ devicebay.installed_device.get_status_display }}
</span>
{% else %}
<span class="label label-default">Vacant</span>
{% endif %}
</td>
{# Description #}
<td>
{{ devicebay.description|placeholder }}
</td>
{# Installed device #}
{% if devicebay.installed_device %}
<td>
<span class="label label-{{ devicebay.installed_device.get_status_class }}">{{ devicebay.installed_device.get_status_display }}</span>
</td>
<td>
<a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
</td>
@ -18,11 +37,10 @@
<span>{{ devicebay.installed_device.device_type.display_name }}</span>
</td>
{% else %}
<td></td>
<td colspan="2">
<span class="text-muted">Vacant</span>
</td>
<td colspan="2"></td>
{% endif %}
{# Actions #}
<td class="text-right">
{% if perms.dcim.change_devicebay %}
{% if devicebay.installed_device %}

View File

@ -1,3 +1,5 @@
{% load helpers %}
<tr class="poweroutlet{% if po.cable.status %} success{% elif po.cable %} info{% endif %}">
{# Checkbox #}
@ -12,12 +14,17 @@
<i class="fa fa-fw fa-bolt"></i> {{ po }}
</td>
{# Description #}
<td>
{{ po.description|placeholder }}
</td>
{# Cable #}
<td>
{% if po.cable %}
<a href="{{ po.cable.get_absolute_url }}">{{ po.cable }}</a>
{% else %}
&mdash;
<span class="text-muted">&mdash;</span>
{% endif %}
</td>

View File

@ -5,6 +5,11 @@
<i class="fa fa-fw fa-bolt"></i> {{ pp }}
</td>
{# Description #}
<td>
{{ pp.description }}
</td>
{# Cable #}
<td>
{% if pp.cable %}

View File

@ -31,6 +31,15 @@
{% endif %}
</div>
<h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=tag %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ tag.get_absolute_url }}">Tag</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %}
@ -59,8 +68,26 @@
{{ items_count }}
</td>
</tr>
<tr>
<td>Color</td>
<td>
<span class="label color-block" style="background-color: #{{ tag.color }}">&nbsp;</span>
</td>
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body rendered-markdown">
{% if tag.comments %}
{{ tag.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
{% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}

View File

@ -0,0 +1,19 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Tag</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.slug %}
{% render_field form.color %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">
{% render_field form.comments %}
</div>
</div>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% load helpers %}
{% if url_name %}
<a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default">{{ tag }}</span></a>
<a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span></a>
{% else %}
<span class="label label-default">{{ tag }}</span>
{% endif %}

View File

@ -0,0 +1,20 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0005_change_logging'),
('extras', '0017_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='tenant',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -3,7 +3,7 @@ from django.db import models
from django.urls import reverse
from taggit.managers import TaggableManager
from extras.models import CustomFieldModel
from extras.models import CustomFieldModel, TaggedItem
from utilities.models import ChangeLoggedModel
@ -70,7 +70,7 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['name', 'slug', 'group', 'description', 'comments']

View File

@ -1,7 +1,8 @@
import django_filters
from django.conf import settings
from django.db.models import Q
from taggit.models import Tag
from extras.models import Tag
class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):

View File

@ -157,7 +157,7 @@ class ObjectListView(View):
# Construct queryset for tags list
if hasattr(model, 'tags'):
tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('name')
tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name')
else:
tags = None

View File

@ -50,16 +50,23 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
def get_serializer_class(self):
"""
Include rendered config context when retrieving a single VirtualMachine.
Select the specific serializer based on the request context.
If the `brief` query param equates to True, return the NestedVirtualMachineSerializer
If the `exclude` query param includes `config_context` as a value, return the VirtualMachineSerializer
Else, return the VirtualMachineWithConfigContextSerializer
"""
if self.action == 'retrieve':
return serializers.VirtualMachineWithConfigContextSerializer
request = self.get_serializer_context()['request']
if request.query_params.get('brief', False):
return serializers.NestedVirtualMachineSerializer
return serializers.VirtualMachineSerializer
elif 'config_context' in request.query_params.get('exclude', []):
return serializers.VirtualMachineSerializer
return serializers.VirtualMachineWithConfigContextSerializer
class InterfaceViewSet(ModelViewSet):

View File

@ -0,0 +1,25 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0008_virtualmachine_local_context_data'),
('extras', '0017_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='cluster',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='virtualmachine',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -6,7 +6,7 @@ from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.models import Device
from extras.models import ConfigContextModel, CustomFieldModel
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from utilities.models import ChangeLoggedModel
from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
@ -119,7 +119,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['name', 'type', 'group', 'site', 'comments']
@ -238,7 +238,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',

View File

@ -337,6 +337,14 @@ class VirtualMachineTest(APITestCase):
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
self.virtualmachine_with_context_data = VirtualMachine.objects.create(
name='VM with context data',
cluster=self.cluster1,
local_context_data={
'A': 1,
'B': 2
}
)
def test_get_virtualmachine(self):
@ -350,7 +358,7 @@ class VirtualMachineTest(APITestCase):
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
self.assertEqual(response.data['count'], 4)
def test_list_virtualmachines_brief(self):
@ -373,7 +381,7 @@ class VirtualMachineTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VirtualMachine.objects.count(), 4)
self.assertEqual(VirtualMachine.objects.count(), 5)
virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine4.name, data['name'])
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
@ -388,7 +396,7 @@ class VirtualMachineTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VirtualMachine.objects.count(), 3)
self.assertEqual(VirtualMachine.objects.count(), 4)
def test_create_virtualmachine_bulk(self):
@ -411,7 +419,7 @@ class VirtualMachineTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VirtualMachine.objects.count(), 6)
self.assertEqual(VirtualMachine.objects.count(), 7)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
@ -438,7 +446,7 @@ class VirtualMachineTest(APITestCase):
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(VirtualMachine.objects.count(), 3)
self.assertEqual(VirtualMachine.objects.count(), 4)
virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine1.name, data['name'])
self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
@ -451,7 +459,22 @@ class VirtualMachineTest(APITestCase):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(VirtualMachine.objects.count(), 2)
self.assertEqual(VirtualMachine.objects.count(), 3)
def test_config_context_included_by_default_in_list_view(self):
url = reverse('virtualization-api:virtualmachine-list')
url = '{}?id__in={}'.format(url, self.virtualmachine_with_context_data.pk)
response = self.client.get(url, **self.header)
self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
def test_config_context_excluded(self):
url = reverse('virtualization-api:virtualmachine-list') + '?exclude=config_context'
response = self.client.get(url, **self.header)
self.assertFalse('config_context' in response.data['results'][0])
class InterfaceTest(APITestCase):