Merge branch 'feature' of https://github.com/netbox-community/netbox into feature

# Conflicts:
#	netbox/dcim/tables/template_code.py
#	netbox/netbox/views/__init__.py
#	netbox/project-static/css/base.css
#	netbox/templates/base.html
#	netbox/templates/circuits/circuit.html
#	netbox/templates/circuits/circuittermination_edit.html
#	netbox/templates/circuits/inc/circuit_termination.html
#	netbox/templates/circuits/provider.html
#	netbox/templates/dcim/device.html
#	netbox/templates/dcim/device/base.html
#	netbox/templates/dcim/device_component.html
#	netbox/templates/dcim/devicetype.html
#	netbox/templates/dcim/inc/device_napalm_tabs.html
#	netbox/templates/dcim/rack.html
#	netbox/templates/dcim/site.html
#	netbox/templates/dcim/virtualchassis.html
#	netbox/templates/extras/configcontext.html
#	netbox/templates/extras/object_journal.html
#	netbox/templates/extras/tag.html
#	netbox/templates/generic/object.html
#	netbox/templates/generic/object_list.html
#	netbox/templates/home.html
#	netbox/templates/inc/nav_menu.html
#	netbox/templates/ipam/aggregate.html
#	netbox/templates/ipam/ipaddress.html
#	netbox/templates/ipam/prefix.html
#	netbox/templates/ipam/vlan.html
#	netbox/templates/ipam/vlangroup_edit.html
#	netbox/templates/ipam/vlangroup_vlans.html
#	netbox/templates/secrets/secret.html
#	netbox/templates/tenancy/tenant.html
#	netbox/templates/users/api_tokens.html
#	netbox/templates/virtualization/cluster.html
#	netbox/templates/virtualization/vminterface_edit.html
#	netbox/utilities/forms/fields.py
#	netbox/utilities/templates/buttons/export.html
This commit is contained in:
checktheroads
2021-04-14 11:30:15 -07:00
256 changed files with 5928 additions and 2111 deletions

View File

@@ -1,16 +1,12 @@
from django import forms
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from utilities.forms import LaxURLField
from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField
from utilities.utils import content_type_name
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
def order_content_types(field):
"""
Order the list of available ContentTypes by application
"""
queryset = field.queryset.order_by('app_label', 'model')
field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
from .utils import FeatureQuery
#
@@ -18,6 +14,10 @@ def order_content_types(field):
#
class WebhookForm(forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks')
)
payload_url = LaxURLField(
label='URL'
)
@@ -26,12 +26,6 @@ class WebhookForm(forms.ModelForm):
model = Webhook
exclude = ()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'content_types' in self.fields:
order_content_types(self.fields['content_types'])
@admin.register(Webhook)
class WebhookAdmin(admin.ModelAdmin):
@@ -70,6 +64,10 @@ class WebhookAdmin(admin.ModelAdmin):
#
class CustomFieldForm(forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
@@ -84,11 +82,6 @@ class CustomFieldForm(forms.ModelForm):
)
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
order_content_types(self.fields['content_types'])
@admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin):
@@ -119,7 +112,8 @@ class CustomFieldAdmin(admin.ModelAdmin):
)
def models(self, obj):
return ', '.join([ct.name for ct in obj.content_types.all()])
ct_names = [content_type_name(ct) for ct in obj.content_types.all()]
return mark_safe('<br/>'.join(ct_names))
#
@@ -127,6 +121,10 @@ class CustomFieldAdmin(admin.ModelAdmin):
#
class CustomLinkForm(forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = CustomLink
@@ -143,13 +141,6 @@ class CustomLinkForm(forms.ModelForm):
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Format ContentType choices
order_content_types(self.fields['content_type'])
self.fields['content_type'].choices.insert(0, ('', '---------'))
@admin.register(CustomLink)
class CustomLinkAdmin(admin.ModelAdmin):
@@ -176,24 +167,21 @@ class CustomLinkAdmin(admin.ModelAdmin):
#
class ExportTemplateForm(forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = ExportTemplate
exclude = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Format ContentType choices
order_content_types(self.fields['content_type'])
self.fields['content_type'].choices.insert(0, ('', '---------'))
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
fieldsets = (
('Export Template', {
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment')
}),
('Content', {
'fields': ('template_code',),
@@ -201,7 +189,7 @@ class ExportTemplateAdmin(admin.ModelAdmin):
})
)
list_display = [
'name', 'content_type', 'description', 'mime_type', 'file_extension',
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
]
list_filter = [
'content_type',

View File

@@ -4,10 +4,10 @@ from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer,
NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
)
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
@@ -116,7 +116,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate
fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
'file_extension',
'file_extension', 'as_attachment',
]
@@ -251,6 +251,12 @@ class ConfigContextSerializer(ValidatedModelSerializer):
required=False,
many=True
)
device_types = SerializedPKRelatedField(
queryset=DeviceType.objects.all(),
serializer=NestedDeviceTypeSerializer,
required=False,
many=True
)
roles = SerializedPKRelatedField(
queryset=DeviceRole.objects.all(),
serializer=NestedDeviceRoleSerializer,
@@ -298,8 +304,8 @@ class ConfigContextSerializer(ValidatedModelSerializer):
model = ConfigContext
fields = [
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created',
'last_updated',
'device_types', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
'data', 'created', 'last_updated',
]

View File

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.forms import DateField, IntegerField, NullBooleanField
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet, ContentTypeFilter
from virtualization.models import Cluster, ClusterGroup
@@ -90,6 +90,7 @@ class CustomFieldModelFilterSet(django_filters.FilterSet):
class CustomFieldFilterSet(django_filters.FilterSet):
content_types = ContentTypeFilter()
class Meta:
model = CustomField
@@ -206,6 +207,11 @@ class ConfigContextFilterSet(BaseFilterSet):
to_field_name='slug',
label='Site (slug)',
)
device_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_types',
queryset=DeviceType.objects.all(),
label='Device type',
)
role_id = django_filters.ModelMultipleChoiceFilter(
field_name='roles',
queryset=DeviceRole.objects.all(),

View File

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
@@ -218,6 +218,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
queryset=Site.objects.all(),
required=False
)
device_types = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False
)
roles = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False
@@ -253,8 +257,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConfigContext
fields = (
'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'platforms',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
)
@@ -306,6 +310,11 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
required=False,
label=_('Sites')
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device types')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
@@ -376,6 +385,7 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
#
class JournalEntryForm(BootstrapMixin, forms.ModelForm):
comments = CommentField()
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),

View File

@@ -14,4 +14,9 @@ class Migration(migrations.Migration):
name='site_groups',
field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'),
),
migrations.AddField(
model_name='configcontext',
name='device_types',
field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_device_types_+', to='dcim.DeviceType'),
),
]

View File

@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0056_sitegroup'),
('extras', '0056_extend_configcontext'),
]
operations = [

View File

@@ -18,6 +18,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(primary_key=True, serialize=False)),
('assigned_object_id', models.PositiveIntegerField()),
('created', models.DateTimeField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('kind', models.CharField(default='info', max_length=30)),
('comments', models.TextField()),
('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0058_journalentry'),
]
operations = [
migrations.AddField(
model_name='exporttemplate',
name='as_attachment',
field=models.BooleanField(default=True),
),
]

View File

@@ -56,6 +56,11 @@ class ConfigContext(ChangeLoggedModel):
related_name='+',
blank=True
)
device_types = models.ManyToManyField(
to='dcim.DeviceType',
related_name='+',
blank=True
)
roles = models.ManyToManyField(
to='dcim.DeviceRole',
related_name='+',

View File

@@ -7,13 +7,15 @@ from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format, time_format
from rest_framework.utils.encoders import JSONEncoder
from extras.choices import *
from extras.constants import *
from extras.utils import extras_features, FeatureQuery, image_upload
from netbox.models import BigIDModel
from netbox.models import BigIDModel, ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2
@@ -151,7 +153,7 @@ class Webhook(BigIDModel):
ret = {}
data = render_jinja2(self.additional_headers, context)
for line in data.splitlines():
header, value = line.split(':')
header, value = line.split(':', 1)
ret[header.strip()] = value.strip()
return ret
@@ -251,6 +253,10 @@ class ExportTemplate(BigIDModel):
blank=True,
help_text='Extension to append to the rendered filename'
)
as_attachment = models.BooleanField(
default=True,
help_text="Download file as attachment"
)
objects = RestrictedQuerySet.as_manager()
@@ -261,7 +267,15 @@ class ExportTemplate(BigIDModel):
]
def __str__(self):
return '{}: {}'.format(self.content_type, self.name)
return f"{self.content_type}: {self.name}"
def clean(self):
super().clean()
if self.name.lower() == 'table':
raise ValidationError({
'name': f'"{self.name}" is a reserved name. Please choose a different name.'
})
def render(self, queryset):
"""
@@ -290,7 +304,9 @@ class ExportTemplate(BigIDModel):
queryset.model._meta.verbose_name_plural,
'.{}'.format(self.file_extension) if self.file_extension else ''
)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
if self.as_attachment:
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
@@ -375,7 +391,7 @@ class ImageAttachment(BigIDModel):
# Journal entries
#
class JournalEntry(BigIDModel):
class JournalEntry(ChangeLoggedModel):
"""
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
@@ -413,7 +429,10 @@ class JournalEntry(BigIDModel):
verbose_name_plural = 'journal entries'
def __str__(self):
return f"{self.created} - {self.get_kind_display()}"
return f"{date_format(self.created)} - {time_format(self.created)} ({self.get_kind_display()})"
def get_absolute_url(self):
return reverse('extras:journalentry', args=[self.pk])
def get_kind_class(self):
return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)

View File

@@ -1,4 +1,5 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
@@ -30,6 +31,9 @@ class Tag(ChangeLoggedModel, TagBase):
class Meta:
ordering = ['name']
def get_absolute_url(self):
return reverse('extras:tag', args=[self.pk])
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
slug = slugify(tag, allow_unicode=True)

View File

@@ -19,7 +19,10 @@ class ConfigContextQuerySet(RestrictedQuerySet):
# `device_role` for Device; `role` for VirtualMachine
role = getattr(obj, 'device_role', None) or obj.role
# Virtualization cluster for VirtualMachine
# Device type assignment is relevant only for Devices
device_type = getattr(obj, 'device_type', None)
# Cluster assignment is relevant only for VirtualMachines
cluster = getattr(obj, 'cluster', None)
cluster_group = getattr(cluster, 'group', None)
@@ -28,14 +31,17 @@ class ConfigContextQuerySet(RestrictedQuerySet):
# Match against the directly assigned region as well as any parent regions.
region = getattr(obj.site, 'region', None)
if region:
regions = region.get_ancestors(include_self=True)
else:
regions = []
regions = region.get_ancestors(include_self=True) if region else []
# Match against the directly assigned site group as well as any parent site groups.
sitegroup = getattr(obj.site, 'group', None)
sitegroups = sitegroup.get_ancestors(include_self=True) if sitegroup else []
queryset = self.filter(
Q(regions__in=regions) | Q(regions=None),
Q(site_groups__in=sitegroups) | Q(site_groups=None),
Q(sites=obj.site) | Q(sites=None),
Q(device_types=device_type) | Q(device_types=None),
Q(roles=role) | Q(roles=None),
Q(platforms=obj.platform) | Q(platforms=None),
Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
@@ -108,14 +114,17 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
)
if self.model._meta.model_name == 'device':
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
region_field = 'site__region'
sitegroup_field = 'site__group'
elif self.model._meta.model_name == 'virtualmachine':
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
region_field = 'cluster__site__region'
sitegroup_field = 'cluster__site__group'
base_query.add(
(Q(
@@ -127,4 +136,14 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
Q.AND
)
base_query.add(
(Q(
site_groups__tree_id=OuterRef(f'{sitegroup_field}__tree_id'),
site_groups__level__lte=OuterRef(f'{sitegroup_field}__level'),
site_groups__lft__lte=OuterRef(f'{sitegroup_field}__lft'),
site_groups__rght__gte=OuterRef(f'{sitegroup_field}__rght'),
) | Q(site_groups=None)),
Q.AND
)
return base_query

View File

@@ -4,6 +4,7 @@ from datetime import timedelta
from cacheops.signals import cache_invalidated, cache_read
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import DEFAULT_DB_ALIAS
from django.db.models.signals import m2m_changed, pre_delete
from django.utils import timezone
from django_prometheus.models import model_deletes, model_inserts, model_updates
@@ -22,26 +23,35 @@ def _handle_changed_object(request, sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
# Queue the object for processing once the request completes
m2m_changed = False
# Determine the type of change being made
if kwargs.get('created'):
action = ObjectChangeActionChoices.ACTION_CREATE
elif 'created' in kwargs:
action = ObjectChangeActionChoices.ACTION_UPDATE
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
# m2m_changed with objects added or removed
m2m_changed = True
action = ObjectChangeActionChoices.ACTION_UPDATE
else:
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(action)
# TODO: Move this to to_objectchange()
if hasattr(instance, '_prechange_snapshot'):
objectchange.prechange_data = instance._prechange_snapshot
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, action)
@@ -55,7 +65,7 @@ def _handle_changed_object(request, sender, instance, **kwargs):
# Housekeeping: 0.1% chance of clearing out expired ObjectChanges
if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
ObjectChange.objects.filter(time__lt=cutoff).delete()
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
def _handle_deleted_object(request, sender, instance, **kwargs):
@@ -65,9 +75,6 @@ def _handle_deleted_object(request, sender, instance, **kwargs):
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
# TODO: Move this to to_objectchange()
if hasattr(instance, '_prechange_snapshot'):
objectchange.prechange_data = instance._prechange_snapshot
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()

View File

@@ -1,17 +1,11 @@
import django_tables2 as tables
from django.conf import settings
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn,
)
from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem
TAGGED_ITEM = """
{% if value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
{{ value }}
{% endif %}
"""
CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %}
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
@@ -36,6 +30,9 @@ OBJECTCHANGE_REQUEST_ID = """
class TagTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
color = ColorColumn()
actions = ButtonsColumn(Tag)
@@ -45,23 +42,25 @@ class TagTable(BaseTable):
class TaggedItemTable(BaseTable):
content_object = tables.TemplateColumn(
template_code=TAGGED_ITEM,
content_type = ContentTypeColumn(
verbose_name='Type'
)
content_object = tables.Column(
linkify=True,
orderable=False,
verbose_name='Object'
)
content_type = tables.Column(
verbose_name='Type'
)
class Meta(BaseTable.Meta):
model = TaggedItem
fields = ('content_object', 'content_type')
fields = ('content_type', 'content_object')
class ConfigContextTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
name = tables.Column(
linkify=True
)
is_active = BooleanColumn(
verbose_name='Active'
)
@@ -81,7 +80,7 @@ class ObjectChangeTable(BaseTable):
format=settings.SHORT_DATETIME_FORMAT
)
action = ChoiceFieldColumn()
changed_object_type = tables.Column(
changed_object_type = ContentTypeColumn(
verbose_name='Type'
)
object_repr = tables.TemplateColumn(
@@ -98,12 +97,30 @@ class ObjectChangeTable(BaseTable):
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
class JournalEntryTable(BaseTable):
pk = ToggleColumn()
class ObjectJournalTable(BaseTable):
"""
Used for displaying a set of JournalEntries within the context of a single object.
"""
created = tables.DateTimeColumn(
linkify=True,
format=settings.SHORT_DATETIME_FORMAT
)
assigned_object_type = tables.Column(
kind = ChoiceFieldColumn()
comments = tables.TemplateColumn(
template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}'
)
actions = ButtonsColumn(
model=JournalEntry
)
class Meta(BaseTable.Meta):
model = JournalEntry
fields = ('created', 'created_by', 'kind', 'comments', 'actions')
class JournalEntryTable(ObjectJournalTable):
pk = ToggleColumn()
assigned_object_type = ContentTypeColumn(
verbose_name='Object type'
)
assigned_object = tables.Column(
@@ -111,32 +128,9 @@ class JournalEntryTable(BaseTable):
orderable=False,
verbose_name='Object'
)
kind = ChoiceFieldColumn()
actions = ButtonsColumn(
model=JournalEntry,
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = JournalEntry
fields = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions'
)
class ObjectJournalTable(BaseTable):
"""
Used for displaying a set of JournalEntries within the context of a single object.
"""
created = tables.DateTimeColumn(
format=settings.SHORT_DATETIME_FORMAT
)
kind = ChoiceFieldColumn()
actions = ButtonsColumn(
model=JournalEntry,
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = JournalEntry
fields = ('created', 'created_by', 'kind', 'comments', 'actions')

View File

@@ -56,19 +56,18 @@ class ChangeLogViewTest(ModelViewTestCase):
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
# Verify the creation of a new ObjectChange record
site = Site.objects.get(name='Site 1')
# First OC is the creation; second is the tags update
oc_list = ObjectChange.objects.filter(
oc = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
).order_by('pk')
self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].prechange_data, None)
self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None)
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Site 1', slug='site-1')
@@ -93,8 +92,8 @@ class ChangeLogViewTest(ModelViewTestCase):
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
# Verify the creation of a new ObjectChange record
site.refresh_from_db()
# Get only the most recent OC
oc = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
@@ -259,17 +258,15 @@ class ChangeLogAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
site = Site.objects.get(pk=response.data['id'])
# First OC is the creation; second is the tags update
oc_list = ObjectChange.objects.filter(
oc = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
).order_by('pk')
self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].prechange_data, None)
self.assertEqual(oc_list[0].postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None)
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Site 1', slug='site-1')
@@ -294,11 +291,10 @@ class ChangeLogAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK)
site = Site.objects.get(pk=response.data['id'])
# Get only the most recent OC
oc = ObjectChange.objects.filter(
oc = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
).first()
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])

View File

@@ -4,7 +4,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
from extras.filters import *
from extras.models import *
@@ -379,6 +379,14 @@ class ConfigContextTestCase(TestCase):
)
Site.objects.bulk_create(sites)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-3'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-4'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
@@ -433,6 +441,7 @@ class ConfigContextTestCase(TestCase):
c.regions.set([regions[i]])
c.site_groups.set([site_groups[i]])
c.sites.set([sites[i]])
c.device_types.set([device_types[i]])
c.roles.set([device_roles[i]])
c.platforms.set([platforms[i]])
c.cluster_groups.set([cluster_groups[i]])
@@ -475,6 +484,11 @@ class ConfigContextTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self):
device_roles = DeviceRole.objects.all()[:2]
params = {'role_id': [device_roles[0].pk, device_roles[1].pk]}

View File

@@ -1,6 +1,6 @@
from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Site, Region
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, Tag
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -28,7 +28,8 @@ class ConfigContextTest(TestCase):
self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
self.region = Region.objects.create(name="Region")
self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region)
self.sitegroup = SiteGroup.objects.create(name="Site Group")
self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup)
self.platform = Platform.objects.create(name="Platform")
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
@@ -160,6 +161,14 @@ class ConfigContextTest(TestCase):
}
)
region_context.regions.add(self.region)
sitegroup_context = ConfigContext.objects.create(
name="sitegroup",
weight=100,
data={
"sitegroup": 1
}
)
sitegroup_context.site_groups.add(self.sitegroup)
platform_context = ConfigContext.objects.create(
name="platform",
weight=100,
@@ -224,6 +233,14 @@ class ConfigContextTest(TestCase):
}
)
region_context.regions.add(self.region)
sitegroup_context = ConfigContext.objects.create(
name="sitegroup",
weight=100,
data={
"sitegroup": 1
}
)
sitegroup_context.site_groups.add(self.sitegroup)
platform_context = ConfigContext.objects.create(
name="platform",
weight=100,

View File

@@ -1,7 +1,7 @@
from django.urls import path
from extras import views
from extras.models import ConfigContext, Tag
from extras.models import ConfigContext, JournalEntry, Tag
app_name = 'extras'
@@ -13,6 +13,7 @@ urlpatterns = [
path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<int:pk>/', views.TagView.as_view(), name='tag'),
path('tags/<int:pk>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path('tags/<int:pk>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path('tags/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
@@ -36,8 +37,10 @@ urlpatterns = [
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
path('journal-entries/<int:pk>/', views.JournalEntryView.as_view(), name='journalentry'),
path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
path('journal-entries/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', kwargs={'model': JournalEntry}),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),

View File

@@ -1,18 +1,16 @@
from django import template
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.db.models import Count, Q
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from django_rq.queues import get_connection
from django_tables2 import RequestConfig
from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.tables import paginate_table
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
from . import filters, forms, tables
@@ -35,6 +33,31 @@ class TagListView(generic.ObjectListView):
table = tables.TagTable
class TagView(generic.ObjectView):
queryset = Tag.objects.all()
def get_extra_context(self, request, instance):
tagged_items = TaggedItem.objects.filter(tag=instance)
taggeditem_table = tables.TaggedItemTable(
data=tagged_items,
orderable=False
)
paginate_table(taggeditem_table, request)
object_types = [
{
'content_type': ContentType.objects.get(pk=ti['content_type']),
'item_count': ti['item_count']
} for ti in tagged_items.values('content_type').annotate(item_count=Count('pk'))
]
return {
'taggeditem_table': taggeditem_table,
'tagged_item_count': tagged_items.count(),
'object_types': object_types,
}
class TagEditView(generic.ObjectEditView):
queryset = Tag.objects.all()
model_form = forms.TagForm
@@ -230,23 +253,12 @@ class ObjectChangeLogView(View):
data=objectchanges,
orderable=False
)
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(objectchanges_table)
paginate_table(objectchanges_table, request)
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
# fall back to using base.html.
if self.base_template is None:
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
# TODO: This can be removed once an object view has been established for every model.
try:
template.loader.get_template(self.base_template)
except template.TemplateDoesNotExist:
self.base_template = 'base.html'
return render(request, 'extras/object_changelog.html', {
'object': obj,
@@ -294,6 +306,10 @@ class JournalEntryListView(generic.ObjectListView):
action_buttons = ('export',)
class JournalEntryView(generic.ObjectView):
queryset = JournalEntry.objects.all()
class JournalEntryEditView(generic.ObjectEditView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryForm
@@ -355,17 +371,8 @@ class ObjectJournalView(View):
assigned_object_type=content_type,
assigned_object_id=obj.pk
)
journalentry_table = tables.ObjectJournalTable(
data=journalentries,
orderable=False
)
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(journalentry_table)
journalentry_table = tables.ObjectJournalTable(journalentries)
paginate_table(journalentry_table, request)
if request.user.has_perm('extras.add_journalentry'):
form = forms.JournalEntryForm(
@@ -381,11 +388,6 @@ class ObjectJournalView(View):
# fall back to using base.html.
if self.base_template is None:
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
# TODO: This can be removed once an object view has been established for every model.
try:
template.loader.get_template(self.base_template)
except template.TemplateDoesNotExist:
self.base_template = 'base.html'
return render(request, 'extras/object_journal.html', {
'object': obj,