Merge branch 'feature' into 18417-rack-height

This commit is contained in:
Arthur 2025-03-20 11:10:01 -07:00
commit 55aadb69ae
77 changed files with 803 additions and 241 deletions

View File

@ -2,7 +2,10 @@
## ALLOW_TOKEN_RETRIEVAL ## ALLOW_TOKEN_RETRIEVAL
Default: True Default: False
!!! note
The default value of this parameter changed from true to false in NetBox v4.3.0.
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.

View File

@ -24,6 +24,12 @@ Jinja2 template code for rendering the exported data.
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`. The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
### File Name
The file name to give to the rendered export file (optional).
!!! info "This field was introduced in NetBox v4.3."
### File Extension ### File Extension
The file extension to append to the file name in the response (optional). The file extension to append to the file name in the response (optional).

View File

@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
The color to use when displaying the tag in the NetBox UI. The color to use when displaying the tag in the NetBox UI.
### Weight
A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**.
!!! info "This field was introduced in NetBox v4.3."
### Object Types ### Object Types
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.

View File

@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj
## Fields ## Fields
### Group ### Groups
The [contact group](./contactgroup.md) to which this contact is assigned (if any). The [contact groups](./contactgroup.md) to which this contact is assigned (if any).
!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group."
### Name ### Name

View File

@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer):
model = Region model = Region
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', 'prefix_count', '_depth', 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
@ -41,7 +41,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
model = SiteGroup model = SiteGroup
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', 'prefix_count', '_depth', 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer):
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count',
'prefix_count', '_depth', 'prefix_count', 'comments', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')

View File

@ -11,7 +11,8 @@ from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
from netbox.choices import ColorChoices from netbox.choices import ColorChoices
from netbox.filtersets import ( from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
OrganizationalModelFilterSet,
) )
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.models import * from tenancy.models import *
@ -81,7 +82,7 @@ __all__ = (
) )
class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
label=_('Parent region (ID)'), label=_('Parent region (ID)'),
@ -111,7 +112,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
fields = ('id', 'name', 'slug', 'description') fields = ('id', 'name', 'slug', 'description')
class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
label=_('Parent site group (ID)'), label=_('Parent site group (ID)'),
@ -205,7 +206,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
return queryset.filter(qs_filter).distinct() return queryset.filter(qs_filter).distinct()
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='site__region', field_name='site__region',
@ -275,13 +276,13 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
fields = ('id', 'name', 'slug', 'facility', 'description') fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): # extended in order to include querying on Location.facility
queryset = super().search(queryset, name, value)
if value.strip():
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
return queryset return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(facility__icontains=value) |
Q(description__icontains=value)
)
class RackRoleFilterSet(OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet):

View File

@ -78,12 +78,13 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField()
model = Region model = Region
fieldsets = ( fieldsets = (
FieldSet('parent', 'description'), FieldSet('parent', 'description'),
) )
nullable_fields = ('parent', 'description') nullable_fields = ('parent', 'description', 'comments')
class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
@ -97,12 +98,13 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField()
model = SiteGroup model = SiteGroup
fieldsets = ( fieldsets = (
FieldSet('parent', 'description'), FieldSet('parent', 'description'),
) )
nullable_fields = ('parent', 'description') nullable_fields = ('parent', 'description', 'comments')
class SiteBulkEditForm(NetBoxModelBulkEditForm): class SiteBulkEditForm(NetBoxModelBulkEditForm):
@ -197,12 +199,13 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField()
model = Location model = Location
fieldsets = ( fieldsets = (
FieldSet('site', 'parent', 'status', 'tenant', 'description'), FieldSet('site', 'parent', 'status', 'tenant', 'description'),
) )
nullable_fields = ('parent', 'tenant', 'description') nullable_fields = ('parent', 'tenant', 'description', 'comments')
class RackRoleBulkEditForm(NetBoxModelBulkEditForm): class RackRoleBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -68,7 +68,7 @@ class RegionImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Region model = Region
fields = ('name', 'slug', 'parent', 'description', 'tags') fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
class SiteGroupImportForm(NetBoxModelImportForm): class SiteGroupImportForm(NetBoxModelImportForm):
@ -82,7 +82,7 @@ class SiteGroupImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = SiteGroup model = SiteGroup
fields = ('name', 'slug', 'parent', 'description') fields = ('name', 'slug', 'parent', 'description', 'comments', 'tags')
class SiteImportForm(NetBoxModelImportForm): class SiteImportForm(NetBoxModelImportForm):
@ -160,7 +160,10 @@ class LocationImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Location model = Location
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags') fields = (
'site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description',
'tags', 'comments',
)
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)

View File

@ -78,6 +78,7 @@ class RegionForm(NetBoxModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags'), FieldSet('parent', 'name', 'slug', 'description', 'tags'),
@ -86,7 +87,7 @@ class RegionForm(NetBoxModelForm):
class Meta: class Meta:
model = Region model = Region
fields = ( fields = (
'parent', 'name', 'slug', 'description', 'tags', 'parent', 'name', 'slug', 'description', 'tags', 'comments',
) )
@ -97,6 +98,7 @@ class SiteGroupForm(NetBoxModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags'), FieldSet('parent', 'name', 'slug', 'description', 'tags'),
@ -105,7 +107,7 @@ class SiteGroupForm(NetBoxModelForm):
class Meta: class Meta:
model = SiteGroup model = SiteGroup
fields = ( fields = (
'parent', 'name', 'slug', 'description', 'tags', 'parent', 'name', 'slug', 'description', 'comments', 'tags',
) )
@ -179,6 +181,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
} }
) )
slug = SlugField() slug = SlugField()
comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')), FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')),
@ -188,7 +191,8 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = Location model = Location
fields = ( fields = (
'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
'facility', 'tags', 'comments',
) )

View File

@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0201_add_power_outlet_status'),
]
operations = [
migrations.AddField(
model_name='location',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='region',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='sitegroup',
name='comments',
field=models.TextField(blank=True),
),
]

View File

@ -144,6 +144,7 @@ class LocationIndex(SearchIndex):
('facility', 100), ('facility', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('site', 'status', 'tenant', 'facility', 'description') display_attrs = ('site', 'status', 'tenant', 'facility', 'description')
@ -317,6 +318,7 @@ class RegionIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('parent', 'description') display_attrs = ('parent', 'description')
@ -343,6 +345,7 @@ class SiteGroupIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('parent', 'description') display_attrs = ('parent', 'description')

View File

@ -32,12 +32,15 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:region_list' url_name='dcim:region_list'
) )
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Region model = Region
fields = ( fields = (
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
'actions', 'created', 'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'site_count', 'description') default_columns = ('pk', 'name', 'site_count', 'description')
@ -59,12 +62,15 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:sitegroup_list' url_name='dcim:sitegroup_list'
) )
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = SiteGroup model = SiteGroup
fields = ( fields = (
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
'actions', 'created', 'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'site_count', 'description') default_columns = ('pk', 'name', 'site_count', 'description')
@ -153,12 +159,15 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
extra_buttons=LOCATION_BUTTONS extra_buttons=LOCATION_BUTTONS
) )
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Location model = Location
fields = ( fields = (
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description'

View File

@ -74,6 +74,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Region 4', 'name': 'Region 4',
'slug': 'region-4', 'slug': 'region-4',
'comments': 'this is region 4, not region 5',
}, },
{ {
'name': 'Region 5', 'name': 'Region 5',
@ -86,13 +87,14 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
] ]
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
'comments': 'New comments',
} }
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
Region.objects.create(name='Region 1', slug='region-1') Region.objects.create(name='Region 1', slug='region-1')
Region.objects.create(name='Region 2', slug='region-2') Region.objects.create(name='Region 2', slug='region-2', comments='what in the world is happening?')
Region.objects.create(name='Region 3', slug='region-3') Region.objects.create(name='Region 3', slug='region-3')
@ -103,26 +105,30 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Site Group 4', 'name': 'Site Group 4',
'slug': 'site-group-4', 'slug': 'site-group-4',
'comments': '',
}, },
{ {
'name': 'Site Group 5', 'name': 'Site Group 5',
'slug': 'site-group-5', 'slug': 'site-group-5',
'comments': 'not actually empty',
}, },
{ {
'name': 'Site Group 6', 'name': 'Site Group 6',
'slug': 'site-group-6', 'slug': 'site-group-6',
'comments': 'Do I really exist?',
}, },
] ]
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
'comments': 'I do exist!',
} }
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
SiteGroup.objects.create(name='Site Group 2', slug='site-group-2') SiteGroup.objects.create(name='Site Group 2', slug='site-group-2', comments='')
SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!')
class SiteTest(APIViewTestCases.APIViewTestCase): class SiteTest(APIViewTestCases.APIViewTestCase):
@ -212,12 +218,14 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
name='Parent Location 1', name='Parent Location 1',
slug='parent-location-1', slug='parent-location-1',
status=LocationStatusChoices.STATUS_ACTIVE, status=LocationStatusChoices.STATUS_ACTIVE,
comments='First!'
), ),
Location.objects.create( Location.objects.create(
site=sites[1], site=sites[1],
name='Parent Location 2', name='Parent Location 2',
slug='parent-location-2', slug='parent-location-2',
status=LocationStatusChoices.STATUS_ACTIVE, status=LocationStatusChoices.STATUS_ACTIVE,
comments='Second!'
), ),
) )
@ -227,6 +235,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
slug='location-1', slug='location-1',
parent=parent_locations[0], parent=parent_locations[0],
status=LocationStatusChoices.STATUS_ACTIVE, status=LocationStatusChoices.STATUS_ACTIVE,
comments='Third!'
) )
Location.objects.create( Location.objects.create(
site=sites[0], site=sites[0],
@ -250,6 +259,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'site': sites[1].pk, 'site': sites[1].pk,
'parent': parent_locations[1].pk, 'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED, 'status': LocationStatusChoices.STATUS_PLANNED,
'comments': '',
}, },
{ {
'name': 'Test Location 5', 'name': 'Test Location 5',
@ -257,6 +267,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'site': sites[1].pk, 'site': sites[1].pk,
'parent': parent_locations[1].pk, 'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED, 'status': LocationStatusChoices.STATUS_PLANNED,
'comments': 'Somebody should check on this location',
}, },
{ {
'name': 'Test Location 6', 'name': 'Test Location 6',

View File

@ -67,9 +67,15 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
parent_regions = ( parent_regions = (
Region(name='Region 1', slug='region-1', description='foobar1'), Region(
Region(name='Region 2', slug='region-2', description='foobar2'), name='Region 1', slug='region-1', description='foobar1', comments="There's nothing that",
Region(name='Region 3', slug='region-3', description='foobar3'), ),
Region(
name='Region 2', slug='region-2', description='foobar2', comments='a hundred men or more',
),
Region(
name='Region 3', slug='region-3', description='foobar3', comments='could ever do'
),
) )
for region in parent_regions: for region in parent_regions:
region.save() region.save()
@ -100,6 +106,13 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_q_comments(self):
params = {'q': 'there'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'q': 'hundred men could'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_name(self): def test_name(self):
params = {'name': ['Region 1', 'Region 2']} params = {'name': ['Region 1', 'Region 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -148,13 +161,17 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]), SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]),
SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]), SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]),
SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]), SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]),
SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]), SiteGroup(
name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2], comments='this is a parent group',
),
) )
for site_group in groups: for site_group in groups:
site_group.save() site_group.save()
child_groups = ( child_groups = (
SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]), SiteGroup(
name='Site Group 1A1', slug='site-group-1a1', parent=groups[0], comments='this is a child group',
),
SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]), SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]),
SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]), SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]),
SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]), SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]),
@ -168,6 +185,13 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_q_comments(self):
params = {'q': 'this'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'q': 'child'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self): def test_name(self):
params = {'name': ['Site Group 1', 'Site Group 2']} params = {'name': ['Site Group 1', 'Site Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -401,6 +425,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_PLANNED, status=LocationStatusChoices.STATUS_PLANNED,
facility='Facility 1', facility='Facility 1',
description='foobar1', description='foobar1',
comments='',
), ),
Location( Location(
name='Location 2A', name='Location 2A',
@ -410,6 +435,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_STAGING, status=LocationStatusChoices.STATUS_STAGING,
facility='Facility 2', facility='Facility 2',
description='foobar2', description='foobar2',
comments='First comment!',
), ),
Location( Location(
name='Location 3A', name='Location 3A',
@ -419,6 +445,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_DECOMMISSIONING, status=LocationStatusChoices.STATUS_DECOMMISSIONING,
facility='Facility 3', facility='Facility 3',
description='foobar3', description='foobar3',
comments='_This_ is a **bold comment**',
), ),
) )
for location in locations: for location in locations:
@ -436,6 +463,13 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_q_comments(self):
params = {'q': 'this'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'q': 'comment'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self): def test_name(self):
params = {'name': ['Location 1', 'Location 2']} params = {'name': ['Location 1', 'Location 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -25,8 +25,10 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
# Create three Regions # Create three Regions
regions = ( regions = (
Region(name='Region 1', slug='region-1'), Region(name='Region 1', slug='region-1', comments=''),
Region(name='Region 2', slug='region-2'), Region(
name='Region 2', slug='region-2', comments="It's going to take a lot to drag me away from you"
),
Region(name='Region 3', slug='region-3'), Region(name='Region 3', slug='region-3'),
) )
for region in regions: for region in regions:
@ -40,13 +42,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'parent': regions[2].pk, 'parent': regions[2].pk,
'description': 'A new region', 'description': 'A new region',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
'comments': 'This comment is really exciting!',
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,description", "name,slug,description,comments",
"Region 4,region-4,Fourth region", "Region 4,region-4,Fourth region,",
"Region 5,region-5,Fifth region", "Region 5,region-5,Fifth region,hi guys",
"Region 6,region-6,Sixth region", "Region 6,region-6,Sixth region,bye guys",
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -58,6 +61,7 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'comments': 'This comment is super exciting!!!',
} }
@ -69,7 +73,7 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
# Create three SiteGroups # Create three SiteGroups
sitegroups = ( sitegroups = (
SiteGroup(name='Site Group 1', slug='site-group-1'), SiteGroup(name='Site Group 1', slug='site-group-1', comments='Still here'),
SiteGroup(name='Site Group 2', slug='site-group-2'), SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'), SiteGroup(name='Site Group 3', slug='site-group-3'),
) )
@ -84,24 +88,26 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'parent': sitegroups[2].pk, 'parent': sitegroups[2].pk,
'description': 'A new site group', 'description': 'A new site group',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
'comments': 'still here',
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,description", "name,slug,description,comments",
"Site Group 4,site-group-4,Fourth site group", "Site Group 4,site-group-4,Fourth site group,",
"Site Group 5,site-group-5,Fifth site group", "Site Group 5,site-group-5,Fifth site group,still hear",
"Site Group 6,site-group-6,Sixth site group", "Site Group 6,site-group-6,Sixth site group,"
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,description", "id,name,description,comments",
f"{sitegroups[0].pk},Site Group 7,Fourth site group7", f"{sitegroups[0].pk},Site Group 7,Fourth site group7,",
f"{sitegroups[1].pk},Site Group 8,Fifth site group8", f"{sitegroups[1].pk},Site Group 8,Fifth site group8,when will it end",
f"{sitegroups[2].pk},Site Group 0,Sixth site group9", f"{sitegroups[2].pk},Site Group 0,Sixth site group9,",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'comments': 'the end',
} }
@ -202,6 +208,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site, site=site,
status=LocationStatusChoices.STATUS_ACTIVE, status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant, tenant=tenant,
comments='',
), ),
Location( Location(
name='Location 2', name='Location 2',
@ -209,6 +216,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site, site=site,
status=LocationStatusChoices.STATUS_ACTIVE, status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant, tenant=tenant,
comments='First comment!',
), ),
Location( Location(
name='Location 3', name='Location 3',
@ -216,6 +224,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site, site=site,
status=LocationStatusChoices.STATUS_ACTIVE, status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant, tenant=tenant,
comments='_This_ is a **bold comment**',
), ),
) )
for location in locations: for location in locations:
@ -232,24 +241,26 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'tenant': tenant.pk, 'tenant': tenant.pk,
'description': 'A new location', 'description': 'A new location',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
'comments': 'This comment is really boring',
} }
cls.csv_data = ( cls.csv_data = (
"site,tenant,name,slug,status,description", "site,tenant,name,slug,status,description,comments",
"Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location,",
"Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location,",
"Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location,hi!",
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,description", "id,name,description,comments",
f"{locations[0].pk},Location 7,Fourth location7", f"{locations[0].pk},Location 7,Fourth location7,Useful comment",
f"{locations[1].pk},Location 8,Fifth location8", f"{locations[1].pk},Location 8,Fifth location8,unuseful comment",
f"{locations[2].pk},Location 0,Sixth location9", f"{locations[2].pk},Location 0,Sixth location9,",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'comments': 'This comment is also really boring',
} }

View File

@ -27,7 +27,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate model = ExportTemplate
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type', 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced',
'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -27,8 +27,8 @@ class TagSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = Tag model = Tag
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'weight',
'tagged_items', 'created', 'last_updated', 'object_types', 'tagged_items', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')

View File

@ -258,8 +258,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled', 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
'data_synced', 'auto_sync_enabled', 'data_synced',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -267,7 +267,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(description__icontains=value) Q(description__icontains=value) |
Q(file_name__icontains=value)
) )
@ -450,7 +451,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
class Meta: class Meta:
model = Tag model = Tag
fields = ('id', 'name', 'slug', 'color', 'description', 'object_types') fields = ('id', 'name', 'slug', 'color', 'weight', 'description', 'object_types')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -155,6 +155,10 @@ class ExportTemplateBulkEditForm(BulkEditForm):
max_length=50, max_length=50,
required=False required=False
) )
file_name = forms.CharField(
label=_('File name'),
required=False
)
file_extension = forms.CharField( file_extension = forms.CharField(
label=_('File extension'), label=_('File extension'),
max_length=15, max_length=15,
@ -166,7 +170,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()
) )
nullable_fields = ('description', 'mime_type', 'file_extension') nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
class SavedFilterBulkEditForm(BulkEditForm): class SavedFilterBulkEditForm(BulkEditForm):
@ -275,6 +279,10 @@ class TagBulkEditForm(BulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
nullable_fields = ('description',) nullable_fields = ('description',)

View File

@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
'template_code',
) )
@ -232,10 +233,14 @@ class EventRuleImportForm(NetBoxModelImportForm):
class TagImportForm(CSVModelForm): class TagImportForm(CSVModelForm):
slug = SlugField() slug = SlugField()
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
class Meta: class Meta:
model = Tag model = Tag
fields = ('name', 'slug', 'color', 'description') fields = ('name', 'slug', 'color', 'weight', 'description')
class JournalEntryImportForm(NetBoxModelImportForm): class JournalEntryImportForm(NetBoxModelImportForm):

View File

@ -162,7 +162,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')), FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')),
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@ -186,6 +186,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
required=False, required=False,
label=_('MIME type') label=_('MIME type')
) )
file_name = forms.CharField(
label=_('File name'),
required=False
)
file_extension = forms.CharField( file_extension = forms.CharField(
label=_('File extension'), label=_('File extension'),
required=False required=False

View File

@ -246,7 +246,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
fieldsets = ( fieldsets = (
FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')), FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
FieldSet('mime_type', 'file_extension', 'as_attachment', name=_('Rendering')), FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
) )
class Meta: class Meta:
@ -490,15 +490,19 @@ class TagForm(forms.ModelForm):
queryset=ObjectType.objects.with_feature('tags'), queryset=ObjectType.objects.with_feature('tags'),
required=False required=False
) )
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
fieldsets = ( fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')), FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
) )
class Meta: class Meta:
model = Tag model = Tag
fields = [ fields = [
'name', 'slug', 'color', 'description', 'object_types', 'name', 'slug', 'color', 'weight', 'description', 'object_types',
] ]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0123_remove_staging'),
]
operations = [
migrations.AlterModelOptions(
name='tag',
options={'ordering': ('weight', 'name')},
),
migrations.AddField(
model_name='tag',
name='weight',
field=models.PositiveSmallIntegerField(default=0),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0124_alter_tag_options_tag_weight'),
]
operations = [
migrations.AddField(
model_name='exporttemplate',
name='file_name',
field=models.CharField(blank=True, max_length=200),
),
]

View File

@ -16,7 +16,7 @@ from core.models import ObjectType
from extras.choices import * from extras.choices import *
from extras.conditions import ConditionSet from extras.conditions import ConditionSet
from extras.constants import * from extras.constants import *
from extras.utils import image_upload from extras.utils import filename_from_model, image_upload
from netbox.config import get_config from netbox.config import get_config
from netbox.events import get_event_type_choices from netbox.events import get_event_type_choices
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
@ -409,6 +409,11 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
verbose_name=_('MIME type'), verbose_name=_('MIME type'),
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>') help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
) )
file_name = models.CharField(
max_length=200,
blank=True,
help_text=_('Filename to give to the rendered export file')
)
file_extension = models.CharField( file_extension = models.CharField(
verbose_name=_('file extension'), verbose_name=_('file extension'),
max_length=15, max_length=15,
@ -422,7 +427,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
) )
clone_fields = ( clone_fields = (
'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment', 'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
) )
class Meta: class Meta:
@ -480,10 +485,10 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
response = HttpResponse(output, content_type=mime_type) response = HttpResponse(output, content_type=mime_type)
if self.as_attachment: if self.as_attachment:
basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
extension = f'.{self.file_extension}' if self.file_extension else '' extension = f'.{self.file_extension}' if self.file_extension else ''
filename = f'netbox_{basename}{extension}' filename = self.file_name or filename_from_model(queryset.model)
response['Content-Disposition'] = f'attachment; filename="{filename}"' full_filename = f'{filename}{extension}'
response['Content-Disposition'] = f'attachment; filename="{full_filename}"'
return response return response

View File

@ -40,13 +40,17 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True, blank=True,
help_text=_("The object type(s) to which this tag can be applied.") help_text=_("The object type(s) to which this tag can be applied.")
) )
weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'),
default=0,
)
clone_fields = ( clone_fields = (
'color', 'description', 'object_types', 'color', 'description', 'object_types',
) )
class Meta: class Meta:
ordering = ['name'] ordering = ('weight', 'name')
verbose_name = _('tag') verbose_name = _('tag')
verbose_name_plural = _('tags') verbose_name_plural = _('tags')

View File

@ -203,11 +203,12 @@ class ExportTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
'data_source', 'data_file', 'data_synced', 'created', 'last_updated', 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced', 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
'as_attachment', 'is_synced',
) )
@ -449,8 +450,8 @@ class TagTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Tag model = Tag
fields = ( fields = (
'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', 'pk', 'id', 'name', 'items', 'slug', 'color', 'weight', 'description', 'object_types',
'actions', 'created', 'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')

View File

@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
'object_types': ['dcim.device'], 'object_types': ['dcim.device'],
'name': 'Test Export Template 6', 'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
'file_name': 'test_export_template_6',
}, },
] ]
bulk_update_data = { bulk_update_data = {
@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
), ),
ExportTemplate( ExportTemplate(
name='Export Template 2', name='Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
file_name='export_template_2',
file_extension='test',
), ),
ExportTemplate( ExportTemplate(
name='Export Template 3', name='Export Template 3',
@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
), ),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
device_object_type = ObjectType.objects.get_for_model(Device)
for et in export_templates: for et in export_templates:
et.object_types.set([ObjectType.objects.get_for_model(Device)]) et.object_types.set([device_object_type])
class TagTest(APIViewTestCases.APIViewTestCase): class TagTest(APIViewTestCases.APIViewTestCase):
@ -513,6 +518,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Tag 4', 'name': 'Tag 4',
'slug': 'tag-4', 'slug': 'tag-4',
'weight': 1000,
}, },
{ {
'name': 'Tag 5', 'name': 'Tag 5',
@ -533,7 +539,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'), Tag(name='Tag 3', slug='tag-3', weight=26),
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)

View File

@ -624,8 +624,11 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), ExportTemplate(
ExportTemplate(name='Export Template 3', template_code='TESTING'), name='Export Template 2', template_code='TESTING', description='foobar2',
file_name='export_template_2', file_extension='nagios',
),
ExportTemplate(name='Export Template 3', template_code='TESTING', file_name='export_filename'),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
for i, et in enumerate(export_templates): for i, et in enumerate(export_templates):
@ -635,6 +638,9 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'q': 'export_filename'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self): def test_name(self):
params = {'name': ['Export Template 1', 'Export Template 2']} params = {'name': ['Export Template 1', 'Export Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -649,6 +655,20 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_file_name(self):
params = {'file_name': ['export_filename']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_file_extension(self):
params = {'file_extension': ['nagios']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'file_name': ['export_template_2'], 'file_extension': ['nagios']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'file_name': 'export_filename', 'file_extension': ['nagios']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests): class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
@ -1196,7 +1216,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'), Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
Tag(name='Tag 3', slug='tag-3', color='0000ff'), Tag(name='Tag 3', slug='tag-3', color='0000ff', weight=1000),
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)
tags[0].object_types.add(object_types['site']) tags[0].object_types.add(object_types['site'])
@ -1249,6 +1269,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
['Tag 2', 'Tag 3'] ['Tag 2', 'Tag 3']
) )
def test_weight(self):
params = {'weight': [1000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'weight': [0]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class TaggedItemFilterSetTestCase(TestCase): class TaggedItemFilterSetTestCase(TestCase):
queryset = TaggedItem.objects.all() queryset = TaggedItem.objects.all()

View File

@ -10,6 +10,40 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
class TagTest(TestCase): class TagTest(TestCase):
def test_default_ordering_weight_then_name_is_set(self):
Tag.objects.create(name='Tag 1', slug='tag-1', weight=100)
Tag.objects.create(name='Tag 2', slug='tag-2')
Tag.objects.create(name='Tag 3', slug='tag-3', weight=10)
Tag.objects.create(name='Tag 4', slug='tag-4', weight=10)
tags = Tag.objects.all()
self.assertEqual(tags[0].slug, 'tag-2')
self.assertEqual(tags[1].slug, 'tag-3')
self.assertEqual(tags[2].slug, 'tag-4')
self.assertEqual(tags[3].slug, 'tag-1')
def test_tag_related_manager_ordering_weight_then_name(self):
tags = [
Tag.objects.create(name='Tag 1', slug='tag-1', weight=100),
Tag.objects.create(name='Tag 2', slug='tag-2'),
Tag.objects.create(name='Tag 3', slug='tag-3', weight=10),
Tag.objects.create(name='Tag 4', slug='tag-4', weight=10),
]
site = Site.objects.create(name='Site 1')
for tag in tags:
site.tags.add(tag)
site.save()
site = Site.objects.first()
tags = site.tags.all()
self.assertEqual(tags[0].slug, 'tag-2')
self.assertEqual(tags[1].slug, 'tag-3')
self.assertEqual(tags[2].slug, 'tag-4')
self.assertEqual(tags[3].slug, 'tag-1')
def test_create_tag_unicode(self): def test_create_tag_unicode(self):
tag = Tag(name='Testing Unicode: 台灣') tag = Tag(name='Testing Unicode: 台灣')
tag.save() tag.save()

View File

@ -0,0 +1,19 @@
from django.test import TestCase
from extras.models import ExportTemplate
from extras.utils import filename_from_model
from tenancy.models import ContactGroup, TenantGroup
from wireless.models import WirelessLANGroup
class FilenameFromModelTests(TestCase):
def test_expected_output(self):
cases = (
(ExportTemplate, 'netbox_export_templates'),
(ContactGroup, 'netbox_contact_groups'),
(TenantGroup, 'netbox_tenant_groups'),
(WirelessLANGroup, 'netbox_wireless_lan_groups'),
)
for model, expected in cases:
self.assertEqual(filename_from_model(model), expected)

View File

@ -305,7 +305,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates: for et in export_templates:
@ -315,13 +315,14 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Export Template X', 'name': 'Export Template X',
'object_types': [site_type.pk], 'object_types': [site_type.pk],
'template_code': TEMPLATE_CODE, 'template_code': TEMPLATE_CODE,
'file_name': 'template_x',
} }
cls.csv_data = ( cls.csv_data = (
"name,object_types,template_code", "name,object_types,template_code,file_name",
f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
f"Export Template 6,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -441,8 +442,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 2', slug='tag-2', weight=1),
Tag(name='Tag 3', slug='tag-3'), Tag(name='Tag 3', slug='tag-3', weight=32767),
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)
@ -451,13 +452,14 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'slug': 'tag-x', 'slug': 'tag-x',
'color': 'c0c0c0', 'color': 'c0c0c0',
'comments': 'Some comments', 'comments': 'Some comments',
'weight': 11,
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,color,description", "name,slug,color,description,weight",
"Tag 4,tag-4,ff0000,Fourth tag", "Tag 4,tag-4,ff0000,Fourth tag,0",
"Tag 5,tag-5,00ff00,Fifth tag", "Tag 5,tag-5,00ff00,Fifth tag,1111",
"Tag 6,tag-6,0000ff,Sixth tag", "Tag 6,tag-6,0000ff,Sixth tag,0",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -1,6 +1,7 @@
import importlib import importlib
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
from netbox.context import current_request from netbox.context import current_request
@ -15,6 +16,12 @@ __all__ = (
) )
def filename_from_model(model: models.Model) -> str:
"""Standardises how we generate filenames from model class for exports"""
base = model._meta.verbose_name_plural.lower().replace(' ', '_')
return f'netbox_{base}'
def is_taggable(obj): def is_taggable(obj):
""" """
Return True if the instance can have Tags assigned to it; False otherwise. Return True if the instance can have Tags assigned to it; False otherwise.

View File

@ -43,6 +43,8 @@ SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
DEFAULT_PERMISSIONS = {} DEFAULT_PERMISSIONS = {}
ALLOW_TOKEN_RETRIEVAL = True
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
'disable_existing_loggers': True 'disable_existing_loggers': True

View File

@ -329,3 +329,19 @@ class OrganizationalModelFilterSet(NetBoxModelFilterSet):
models.Q(slug__icontains=value) | models.Q(slug__icontains=value) |
models.Q(description__icontains=value) models.Q(description__icontains=value)
) )
class NestedGroupModelFilterSet(NetBoxModelFilterSet):
"""
A base FilterSet for models that inherit from NestedGroupModel
"""
def search(self, queryset, name, value):
if value.strip():
queryset = queryset.filter(
models.Q(name__icontains=value) |
models.Q(slug__icontains=value) |
models.Q(description__icontains=value) |
models.Q(comments__icontains=value)
)
return queryset

View File

@ -150,6 +150,10 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
max_length=200, max_length=200,
blank=True blank=True
) )
comments = models.TextField(
verbose_name=_('comments'),
blank=True
)
objects = TreeManager() objects = TreeManager()

View File

@ -455,7 +455,8 @@ class TagsMixin(models.Model):
which is a `TaggableManager` instance. which is a `TaggableManager` instance.
""" """
tags = TaggableManager( tags = TaggableManager(
through='extras.TaggedItem' through='extras.TaggedItem',
ordering=('weight', 'name'),
) )
class Meta: class Meta:

View File

@ -64,7 +64,7 @@ elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'):
# Set static config parameters # Set static config parameters
ADMINS = getattr(configuration, 'ADMINS', []) ADMINS = getattr(configuration, 'ADMINS', [])
ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False)
ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required
AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [
{ {

View File

@ -62,6 +62,7 @@
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -41,6 +41,7 @@
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -41,6 +41,7 @@
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -23,6 +23,10 @@
<th scope="row">{% trans "MIME Type" %}</th> <th scope="row">{% trans "MIME Type" %}</th>
<td>{{ object.mime_type|placeholder }}</td> <td>{{ object.mime_type|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "File Name" %}</th>
<td>{{ object.file_name|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "File Extension" %}</th> <th scope="row">{% trans "File Extension" %}</th>
<td>{{ object.file_extension|placeholder }}</td> <td>{{ object.file_extension|placeholder }}</td>

View File

@ -28,6 +28,10 @@
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span> <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Tagged Items" %}</th> <th scope="row">{% trans "Tagged Items" %}</th>
<td> <td>

View File

@ -18,8 +18,18 @@
<h2 class="card-header">{% trans "Contact" %}</h2> <h2 class="card-header">{% trans "Contact" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Group" %}</th> <th scope="row">{% trans "Groups" %}</th>
<td>{{ object.group|linkify|placeholder }}</td> <td>
{% if object.groups.all|length > 0 %}
<ol class="list-unstyled mb-0">
{% for group in object.groups.all %}
<li>{{ group|linkify|placeholder }}</li>
{% endfor %}
</ol>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Name" %}</th> <th scope="row">{% trans "Name" %}</th>

View File

@ -32,6 +32,7 @@
</table> </table>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -40,6 +40,7 @@
</table> </table>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -40,6 +40,7 @@
</table> </table>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from tenancy.choices import ContactPriorityChoices from tenancy.choices import ContactPriorityChoices
from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole
@ -26,7 +26,7 @@ class ContactGroupSerializer(NestedGroupModelSerializer):
model = ContactGroup model = ContactGroup
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'contact_count', '_depth', 'created', 'last_updated', 'contact_count', 'comments', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth')
@ -43,12 +43,17 @@ class ContactRoleSerializer(NetBoxModelSerializer):
class ContactSerializer(NetBoxModelSerializer): class ContactSerializer(NetBoxModelSerializer):
group = ContactGroupSerializer(nested=True, required=False, allow_null=True, default=None) groups = SerializedPKRelatedField(
queryset=ContactGroup.objects.all(),
serializer=ContactGroupSerializer,
required=False,
many=True
)
class Meta: class Meta:
model = Contact model = Contact
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'id', 'url', 'display_url', 'display', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -19,7 +19,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
model = TenantGroup model = TenantGroup
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'tenant_count', '_depth', 'created', 'last_updated', 'tenant_count', 'comments', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth')

View File

@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = ContactGroup.objects.add_related_count( queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(), ContactGroup.objects.all(),
Contact, Contact,
'group', 'groups',
'contact_count', 'contact_count',
cumulative=True cumulative=True
) )

View File

@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from .models import * from .models import *
@ -22,7 +22,7 @@ __all__ = (
# Contacts # Contacts
# #
class ContactGroupFilterSet(OrganizationalModelFilterSet): class ContactGroupFilterSet(NestedGroupModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
label=_('Parent contact group (ID)'), label=_('Parent contact group (ID)'),
@ -46,6 +46,11 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label=_('Contact group (slug)'), label=_('Contact group (slug)'),
) )
contact_id = django_filters.ModelMultipleChoiceFilter(
field_name='contact',
queryset=Contact.objects.all(),
label=_('Contact (ID)'),
)
class Meta: class Meta:
model = ContactGroup model = ContactGroup
@ -62,15 +67,15 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
class ContactFilterSet(NetBoxModelFilterSet): class ContactFilterSet(NetBoxModelFilterSet):
group_id = TreeNodeMultipleChoiceFilter( group_id = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
field_name='group', field_name='groups',
lookup_expr='in', lookup_expr='in',
label=_('Contact group (ID)'), label=_('Contact group (ID)'),
) )
group = TreeNodeMultipleChoiceFilter( group = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
field_name='group', field_name='groups',
lookup_expr='in',
to_field_name='slug', to_field_name='slug',
lookup_expr='in',
label=_('Contact group (slug)'), label=_('Contact group (slug)'),
) )
@ -105,13 +110,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
) )
group_id = TreeNodeMultipleChoiceFilter( group_id = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
field_name='contact__group', field_name='contact__groups',
lookup_expr='in', lookup_expr='in',
label=_('Contact group (ID)'), label=_('Contact group (ID)'),
) )
group = TreeNodeMultipleChoiceFilter( group = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
field_name='contact__group', field_name='contact__groups',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label=_('Contact group (slug)'), label=_('Contact group (slug)'),
@ -153,7 +158,7 @@ class ContactModelFilterSet(django_filters.FilterSet):
) )
contact_group = TreeNodeMultipleChoiceFilter( contact_group = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
field_name='contacts__contact__group', field_name='contacts__contact__groups',
lookup_expr='in', lookup_expr='in',
label=_('Contact group'), label=_('Contact group'),
) )
@ -163,7 +168,7 @@ class ContactModelFilterSet(django_filters.FilterSet):
# Tenancy # Tenancy
# #
class TenantGroupFilterSet(OrganizationalModelFilterSet): class TenantGroupFilterSet(NestedGroupModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
label=_('Parent tenant group (ID)'), label=_('Parent tenant group (ID)'),

View File

@ -5,7 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.choices import ContactPriorityChoices from tenancy.choices import ContactPriorityChoices
from tenancy.models import * from tenancy.models import *
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import CommentField, DynamicModelChoiceField from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
__all__ = ( __all__ = (
@ -33,9 +33,10 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField()
model = TenantGroup model = TenantGroup
nullable_fields = ('parent', 'description') nullable_fields = ('parent', 'description', 'comments')
class TenantBulkEditForm(NetBoxModelBulkEditForm): class TenantBulkEditForm(NetBoxModelBulkEditForm):
@ -67,12 +68,13 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField()
model = ContactGroup model = ContactGroup
fieldsets = ( fieldsets = (
FieldSet('parent', 'description'), FieldSet('parent', 'description'),
) )
nullable_fields = ('parent', 'description') nullable_fields = ('parent', 'description', 'comments')
class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):
@ -90,8 +92,13 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):
class ContactBulkEditForm(NetBoxModelBulkEditForm): class ContactBulkEditForm(NetBoxModelBulkEditForm):
group = DynamicModelChoiceField( add_groups = DynamicModelMultipleChoiceField(
label=_('Group'), label=_('Add groups'),
queryset=ContactGroup.objects.all(),
required=False
)
remove_groups = DynamicModelMultipleChoiceField(
label=_('Remove groups'),
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
required=False required=False
) )
@ -127,9 +134,13 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):
model = Contact model = Contact
fieldsets = ( fieldsets = (
FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'), FieldSet('title', 'phone', 'email', 'address', 'link', 'description'),
FieldSet('add_groups', 'remove_groups', name=_('Groups')),
)
nullable_fields = (
'add_groups', 'remove_groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments'
) )
nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments')
class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm): class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import * from tenancy.models import *
from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
__all__ = ( __all__ = (
'ContactAssignmentImportForm', 'ContactAssignmentImportForm',
@ -31,7 +31,7 @@ class TenantGroupImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = TenantGroup model = TenantGroup
fields = ('name', 'slug', 'parent', 'description', 'tags') fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
class TenantImportForm(NetBoxModelImportForm): class TenantImportForm(NetBoxModelImportForm):
@ -65,7 +65,7 @@ class ContactGroupImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = ContactGroup model = ContactGroup
fields = ('name', 'slug', 'parent', 'description', 'tags') fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
class ContactRoleImportForm(NetBoxModelImportForm): class ContactRoleImportForm(NetBoxModelImportForm):
@ -77,17 +77,16 @@ class ContactRoleImportForm(NetBoxModelImportForm):
class ContactImportForm(NetBoxModelImportForm): class ContactImportForm(NetBoxModelImportForm):
group = CSVModelChoiceField( groups = CSVModelMultipleChoiceField(
label=_('Group'),
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Assigned group') help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")')
) )
class Meta: class Meta:
model = Contact model = Contact
fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'comments', 'tags')
class ContactAssignmentImportForm(NetBoxModelImportForm): class ContactAssignmentImportForm(NetBoxModelImportForm):

View File

@ -75,7 +75,7 @@ class ContactFilterForm(NetBoxModelFilterSetForm):
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
required=False, required=False,
null_option='None', null_option='None',
label=_('Group') label=_('Groups')
) )
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import * from tenancy.models import *
from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
from utilities.forms.rendering import FieldSet, ObjectAttribute from utilities.forms.rendering import FieldSet, ObjectAttribute
__all__ = ( __all__ = (
@ -27,6 +27,7 @@ class TenantGroupForm(NetBoxModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')), FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')),
@ -35,7 +36,7 @@ class TenantGroupForm(NetBoxModelForm):
class Meta: class Meta:
model = TenantGroup model = TenantGroup
fields = [ fields = [
'parent', 'name', 'slug', 'description', 'tags', 'parent', 'name', 'slug', 'description', 'tags', 'comments'
] ]
@ -70,6 +71,7 @@ class ContactGroupForm(NetBoxModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')), FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')),
@ -77,7 +79,7 @@ class ContactGroupForm(NetBoxModelForm):
class Meta: class Meta:
model = ContactGroup model = ContactGroup
fields = ('parent', 'name', 'slug', 'description', 'tags') fields = ('parent', 'name', 'slug', 'description', 'tags', 'comments')
class ContactRoleForm(NetBoxModelForm): class ContactRoleForm(NetBoxModelForm):
@ -93,8 +95,8 @@ class ContactRoleForm(NetBoxModelForm):
class ContactForm(NetBoxModelForm): class ContactForm(NetBoxModelForm):
group = DynamicModelChoiceField( groups = DynamicModelMultipleChoiceField(
label=_('Group'), label=_('Groups'),
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
required=False required=False
) )
@ -102,7 +104,7 @@ class ContactForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags',
name=_('Contact') name=_('Contact')
), ),
) )
@ -110,7 +112,7 @@ class ContactForm(NetBoxModelForm):
class Meta: class Meta:
model = Contact model = Contact
fields = ( fields = (
'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags',
) )
widgets = { widgets = {
'address': forms.Textarea(attrs={'rows': 3}), 'address': forms.Textarea(attrs={'rows': 3}),
@ -123,7 +125,7 @@ class ContactAssignmentForm(NetBoxModelForm):
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
required=False, required=False,
initial_params={ initial_params={
'contacts': '$contact' 'contact': '$contact'
} }
) )
contact = DynamicModelChoiceField( contact = DynamicModelChoiceField(

View File

@ -97,7 +97,7 @@ class TenantGroupType(OrganizationalObjectType):
@strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter) @strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter)
class ContactType(ContactAssignmentsMixin, NetBoxObjectType): class ContactType(ContactAssignmentsMixin, NetBoxObjectType):
group: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]]
@strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter) @strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter)

View File

@ -0,0 +1,68 @@
import django.db.models.deletion
from django.db import migrations, models
def migrate_contact_groups(apps, schema_editor):
Contacts = apps.get_model('tenancy', 'Contact')
qs = Contacts.objects.filter(group__isnull=False)
for contact in qs:
contact.groups.add(contact.group)
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0017_natural_ordering'),
]
operations = [
migrations.CreateModel(
name='ContactGroupMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
],
options={
'verbose_name': 'contact group membership',
'verbose_name_plural': 'contact group memberships',
},
),
migrations.RemoveConstraint(
model_name='contact',
name='tenancy_contact_unique_group_name',
),
migrations.AddField(
model_name='contactgroupmembership',
name='contact',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contact'
),
),
migrations.AddField(
model_name='contactgroupmembership',
name='group',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contactgroup'
),
),
migrations.AddField(
model_name='contact',
name='groups',
field=models.ManyToManyField(
blank=True,
related_name='contacts',
related_query_name='contact',
through='tenancy.ContactGroupMembership',
to='tenancy.contactgroup',
),
),
migrations.AddConstraint(
model_name='contactgroupmembership',
constraint=models.UniqueConstraint(fields=('group', 'contact'), name='unique_group_name'),
),
migrations.RunPython(code=migrate_contact_groups, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name='contact',
name='group',
),
]

View File

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0018_contact_groups'),
]
operations = [
migrations.AddField(
model_name='contactgroup',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='tenantgroup',
name='comments',
field=models.TextField(blank=True),
),
]

View File

@ -13,6 +13,7 @@ __all__ = (
'ContactAssignment', 'ContactAssignment',
'Contact', 'Contact',
'ContactGroup', 'ContactGroup',
'ContactGroupMembership',
'ContactRole', 'ContactRole',
) )
@ -47,12 +48,12 @@ class Contact(PrimaryModel):
""" """
Contact information for a particular object(s) in NetBox. Contact information for a particular object(s) in NetBox.
""" """
group = models.ForeignKey( groups = models.ManyToManyField(
to='tenancy.ContactGroup', to='tenancy.ContactGroup',
on_delete=models.SET_NULL,
related_name='contacts', related_name='contacts',
blank=True, through='tenancy.ContactGroupMembership',
null=True related_query_name='contact',
blank=True
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
@ -84,17 +85,11 @@ class Contact(PrimaryModel):
) )
clone_fields = ( clone_fields = (
'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link',
) )
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
constraints = (
models.UniqueConstraint(
fields=('group', 'name'),
name='%(app_label)s_%(class)s_unique_group_name'
),
)
verbose_name = _('contact') verbose_name = _('contact')
verbose_name_plural = _('contacts') verbose_name_plural = _('contacts')
@ -102,6 +97,18 @@ class Contact(PrimaryModel):
return self.name return self.name
class ContactGroupMembership(models.Model):
group = models.ForeignKey(ContactGroup, related_name="+", on_delete=models.CASCADE)
contact = models.ForeignKey(Contact, related_name="+", on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(fields=['group', 'contact'], name='unique_group_name')
]
verbose_name = _('contact group membership')
verbose_name_plural = _('contact group memberships')
class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
object_type = models.ForeignKey( object_type = models.ForeignKey(
to='contenttypes.ContentType', to='contenttypes.ContentType',

View File

@ -25,6 +25,7 @@ class ContactGroupIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('description',) display_attrs = ('description',)
@ -59,5 +60,6 @@ class TenantGroupIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('description',) display_attrs = ('description',)

View File

@ -27,11 +27,15 @@ class ContactGroupTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='tenancy:contactgroup_list' url_name='tenancy:contactgroup_list'
) )
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ContactGroup model = ContactGroup
fields = ( fields = (
'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', 'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'contact_count', 'description') default_columns = ('pk', 'name', 'contact_count', 'description')
@ -56,9 +60,9 @@ class ContactTable(NetBoxTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
group = tables.Column( groups = columns.ManyToManyColumn(
verbose_name=_('Group'), verbose_name=_('Groups'),
linkify=True linkify_item=('tenancy:contactgroup', {'pk': tables.A('pk')})
) )
phone = tables.Column( phone = tables.Column(
verbose_name=_('Phone'), verbose_name=_('Phone'),
@ -79,10 +83,10 @@ class ContactTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Contact model = Contact
fields = ( fields = (
'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'pk', 'name', 'groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments',
'assignment_count', 'tags', 'created', 'last_updated', 'assignment_count', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') default_columns = ('pk', 'name', 'groups', 'assignment_count', 'title', 'phone', 'email')
class ContactAssignmentTable(NetBoxTable): class ContactAssignmentTable(NetBoxTable):

View File

@ -24,11 +24,15 @@ class TenantGroupTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='tenancy:tenantgroup_list' url_name='tenancy:tenantgroup_list'
) )
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = TenantGroup model = TenantGroup
fields = ( fields = (
'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', 'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'tenant_count', 'description') default_columns = ('pk', 'name', 'tenant_count', 'description')

View File

@ -21,6 +21,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase):
brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'slug', 'tenant_count', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
'comments': 'New Comment',
} }
@classmethod @classmethod
@ -28,12 +29,17 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase):
parent_tenant_groups = ( parent_tenant_groups = (
TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), TenantGroup.objects.create(
name='Parent Tenant Group 2', slug='parent-tenant-group-2', comments='Parent Group 2 comment',
),
) )
TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0]) TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0])
TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0]) TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0])
TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0]) TenantGroup.objects.create(
name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0],
comments='Tenant Group 3 comment'
)
cls.create_data = [ cls.create_data = [
{ {
@ -50,6 +56,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase):
'name': 'Tenant Group 6', 'name': 'Tenant Group 6',
'slug': 'tenant-group-6', 'slug': 'tenant-group-6',
'parent': parent_tenant_groups[1].pk, 'parent': parent_tenant_groups[1].pk,
'comments': 'Tenant Group 6 comment',
}, },
] ]
@ -107,13 +114,18 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
parent_contact_groups = ( parent_contact_groups = (
ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'), ContactGroup.objects.create(
name='Parent Contact Group 1', slug='parent-contact-group-1', comments='Parent 1 comment'
),
ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'), ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'),
) )
ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0]) ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0])
ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0]) ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0])
ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0]) ContactGroup.objects.create(
name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0],
comments='Child Group 3 comment',
)
cls.create_data = [ cls.create_data = [
{ {
@ -125,11 +137,13 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase):
'name': 'Contact Group 5', 'name': 'Contact Group 5',
'slug': 'contact-group-5', 'slug': 'contact-group-5',
'parent': parent_contact_groups[1].pk, 'parent': parent_contact_groups[1].pk,
'comments': '',
}, },
{ {
'name': 'Contact Group 6', 'name': 'Contact Group 6',
'slug': 'contact-group-6', 'slug': 'contact-group-6',
'parent': parent_contact_groups[1].pk, 'parent': parent_contact_groups[1].pk,
'comments': 'Child Group 6 comment',
}, },
] ]
@ -170,7 +184,7 @@ class ContactTest(APIViewTestCases.APIViewTestCase):
model = Contact model = Contact
brief_fields = ['description', 'display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'group': None, 'groups': [],
'comments': 'New comments', 'comments': 'New comments',
} }
@ -183,20 +197,22 @@ class ContactTest(APIViewTestCases.APIViewTestCase):
) )
contacts = ( contacts = (
Contact(name='Contact 1', group=contact_groups[0]), Contact(name='Contact 1'),
Contact(name='Contact 2', group=contact_groups[0]), Contact(name='Contact 2'),
Contact(name='Contact 3', group=contact_groups[0]), Contact(name='Contact 3'),
) )
Contact.objects.bulk_create(contacts) Contact.objects.bulk_create(contacts)
contacts[0].groups.add(contact_groups[0])
contacts[1].groups.add(contact_groups[0])
contacts[2].groups.add(contact_groups[0])
cls.create_data = [ cls.create_data = [
{ {
'name': 'Contact 4', 'name': 'Contact 4',
'group': contact_groups[1].pk, 'groups': [contact_groups[1].pk],
}, },
{ {
'name': 'Contact 5', 'name': 'Contact 5',
'group': contact_groups[1].pk,
}, },
{ {
'name': 'Contact 6', 'name': 'Contact 6',

View File

@ -16,7 +16,7 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
parent_tenant_groups = ( parent_tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Parent group 2 comment'),
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
) )
for tenant_group in parent_tenant_groups: for tenant_group in parent_tenant_groups:
@ -27,7 +27,8 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
name='Tenant Group 1A', name='Tenant Group 1A',
slug='tenant-group-1a', slug='tenant-group-1a',
parent=parent_tenant_groups[0], parent=parent_tenant_groups[0],
description='foobar1' description='foobar1',
comments='Tenant Group 1A comment',
), ),
TenantGroup( TenantGroup(
name='Tenant Group 2A', name='Tenant Group 2A',
@ -48,7 +49,10 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
child_tenant_groups = ( child_tenant_groups = (
TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]), TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]),
TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]), TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]),
TenantGroup(name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2]), TenantGroup(
name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2],
comments='Tenant Group 3A1 comment',
),
) )
for tenant_group in child_tenant_groups: for tenant_group in child_tenant_groups:
tenant_group.save() tenant_group.save()
@ -57,6 +61,13 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_q_comments(self):
params = {'q': 'parent'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'q': 'comment'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_name(self): def test_name(self):
params = {'name': ['Tenant Group 1', 'Tenant Group 2']} params = {'name': ['Tenant Group 1', 'Tenant Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -139,7 +150,7 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
parent_contact_groups = ( parent_contact_groups = (
ContactGroup(name='Contact Group 1', slug='contact-group-1'), ContactGroup(name='Contact Group 1', slug='contact-group-1'),
ContactGroup(name='Contact Group 2', slug='contact-group-2'), ContactGroup(name='Contact Group 2', slug='contact-group-2', comments='Parent group 2'),
ContactGroup(name='Contact Group 3', slug='contact-group-3'), ContactGroup(name='Contact Group 3', slug='contact-group-3'),
) )
for contact_group in parent_contact_groups: for contact_group in parent_contact_groups:
@ -162,14 +173,18 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
name='Contact Group 3A', name='Contact Group 3A',
slug='contact-group-3a', slug='contact-group-3a',
parent=parent_contact_groups[2], parent=parent_contact_groups[2],
description='foobar3' description='foobar3',
comments='Contact Group 3A comment, not a parent',
), ),
) )
for contact_group in contact_groups: for contact_group in contact_groups:
contact_group.save() contact_group.save()
child_contact_groups = ( child_contact_groups = (
ContactGroup(name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0]), ContactGroup(
name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0],
comments='Contact Group 1A1 comment',
),
ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]), ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]),
ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]), ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]),
) )
@ -180,6 +195,13 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_q_comments(self):
params = {'q': 'parent'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'q': '1A1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self): def test_name(self):
params = {'name': ['Contact Group 1', 'Contact Group 2']} params = {'name': ['Contact Group 1', 'Contact Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -241,6 +263,7 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Contact.objects.all() queryset = Contact.objects.all()
filterset = ContactFilterSet filterset = ContactFilterSet
ignore_fields = ('groups',)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -254,11 +277,14 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
contactgroup.save() contactgroup.save()
contacts = ( contacts = (
Contact(name='Contact 1', group=contact_groups[0], description='foobar1'), Contact(name='Contact 1', description='foobar1'),
Contact(name='Contact 2', group=contact_groups[1], description='foobar2'), Contact(name='Contact 2', description='foobar2'),
Contact(name='Contact 3', group=contact_groups[2], description='foobar3'), Contact(name='Contact 3', description='foobar3'),
) )
Contact.objects.bulk_create(contacts) Contact.objects.bulk_create(contacts)
contacts[0].groups.add(contact_groups[0])
contacts[1].groups.add(contact_groups[1])
contacts[2].groups.add(contact_groups[2])
def test_q(self): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
@ -311,11 +337,14 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
ContactRole.objects.bulk_create(contact_roles) ContactRole.objects.bulk_create(contact_roles)
contacts = ( contacts = (
Contact(name='Contact 1', group=contact_groups[0]), Contact(name='Contact 1'),
Contact(name='Contact 2', group=contact_groups[1]), Contact(name='Contact 2'),
Contact(name='Contact 3', group=contact_groups[2]), Contact(name='Contact 3'),
) )
Contact.objects.bulk_create(contacts) Contact.objects.bulk_create(contacts)
contacts[0].groups.add(contact_groups[0])
contacts[1].groups.add(contact_groups[1])
contacts[2].groups.add(contact_groups[2])
assignments = ( assignments = (
ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]), ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),

View File

@ -15,7 +15,7 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tenant_groups = ( tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Tenant Group 2 comment'),
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
) )
for tenanantgroup in tenant_groups: for tenanantgroup in tenant_groups:
@ -28,24 +28,26 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'slug': 'tenant-group-x', 'slug': 'tenant-group-x',
'description': 'A new tenant group', 'description': 'A new tenant group',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
'comments': 'Tenant Group X comment',
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,description", "name,slug,description,comments",
"Tenant Group 4,tenant-group-4,Fourth tenant group", "Tenant Group 4,tenant-group-4,Fourth tenant group,",
"Tenant Group 5,tenant-group-5,Fifth tenant group", "Tenant Group 5,tenant-group-5,Fifth tenant group,",
"Tenant Group 6,tenant-group-6,Sixth tenant group", "Tenant Group 6,tenant-group-6,Sixth tenant group,Sixth tenant group comment",
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,description", "id,name,description,comments",
f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7", f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7,Group 7 comment",
f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8", f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8,",
f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9", f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9,",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'comments': 'New comment',
} }
@ -106,7 +108,7 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
contact_groups = ( contact_groups = (
ContactGroup(name='Contact Group 1', slug='contact-group-1'), ContactGroup(name='Contact Group 1', slug='contact-group-1', comments='Comment 1'),
ContactGroup(name='Contact Group 2', slug='contact-group-2'), ContactGroup(name='Contact Group 2', slug='contact-group-2'),
ContactGroup(name='Contact Group 3', slug='contact-group-3'), ContactGroup(name='Contact Group 3', slug='contact-group-3'),
) )
@ -120,24 +122,26 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'slug': 'contact-group-x', 'slug': 'contact-group-x',
'description': 'A new contact group', 'description': 'A new contact group',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
'comments': 'Form data comment',
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,description", "name,slug,description,comments",
"Contact Group 4,contact-group-4,Fourth contact group", "Contact Group 4,contact-group-4,Fourth contact group,",
"Contact Group 5,contact-group-5,Fifth contact group", "Contact Group 5,contact-group-5,Fifth contact group,Fifth comment",
"Contact Group 6,contact-group-6,Sixth contact group", "Contact Group 6,contact-group-6,Sixth contact group,",
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,description", "id,name,description,comments",
f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7", f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7,",
f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8", f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8,Group 8 comment",
f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9", f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9,",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'comments': 'Bulk update comment',
} }
@ -196,37 +200,40 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase):
contactgroup.save() contactgroup.save()
contacts = ( contacts = (
Contact(name='Contact 1', group=contact_groups[0]), Contact(name='Contact 1'),
Contact(name='Contact 2', group=contact_groups[0]), Contact(name='Contact 2'),
Contact(name='Contact 3', group=contact_groups[0]), Contact(name='Contact 3'),
) )
Contact.objects.bulk_create(contacts) Contact.objects.bulk_create(contacts)
contacts[0].groups.add(contact_groups[0])
contacts[1].groups.add(contact_groups[1])
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Contact X', 'name': 'Contact X',
'group': contact_groups[1].pk, 'groups': [contact_groups[1].pk],
'comments': 'Some comments', 'comments': 'Some comments',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
"group,name", "name",
"Contact Group 1,Contact 4", "groups",
"Contact Group 1,Contact 5", "Contact 4",
"Contact Group 1,Contact 6", "Contact 5",
"Contact 6",
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,comments", "id,name,groups,comments",
f"{contacts[0].pk},Contact Group 7,New comments 7", f'{contacts[0].pk},Contact 7,"Contact Group 1,Contact Group 2",New comments 7',
f"{contacts[1].pk},Contact Group 8,New comments 8", f'{contacts[1].pk},Contact 8,"Contact Group 1",New comments 8',
f"{contacts[2].pk},Contact Group 9,New comments 9", f'{contacts[2].pk},Contact 9,"Contact Group 1",New comments 9',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'group': contact_groups[1].pk, 'description': "New description",
} }

View File

@ -170,7 +170,7 @@ class ContactGroupListView(generic.ObjectListView):
queryset = ContactGroup.objects.add_related_count( queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(), ContactGroup.objects.all(),
Contact, Contact,
'group', 'groups',
'contact_count', 'contact_count',
cumulative=True cumulative=True
) )
@ -214,7 +214,7 @@ class ContactGroupBulkEditView(generic.BulkEditView):
queryset = ContactGroup.objects.add_related_count( queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(), ContactGroup.objects.all(),
Contact, Contact,
'group', 'groups',
'contact_count', 'contact_count',
cumulative=True cumulative=True
) )
@ -228,7 +228,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ContactGroup.objects.add_related_count( queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(), ContactGroup.objects.all(),
Contact, Contact,
'group', 'groups',
'contact_count', 'contact_count',
cumulative=True cumulative=True
) )
@ -337,6 +337,15 @@ class ContactBulkEditView(generic.BulkEditView):
table = tables.ContactTable table = tables.ContactTable
form = forms.ContactBulkEditForm form = forms.ContactBulkEditForm
def post_save_operations(self, form, obj):
super().post_save_operations(form, obj)
# Add/remove groups
if form.cleaned_data.get('add_groups', None):
obj.groups.add(*form.cleaned_data['add_groups'])
if form.cleaned_data.get('remove_groups', None):
obj.groups.remove(*form.cleaned_data['remove_groups'])
@register_model_view(Contact, 'bulk_delete', path='delete', detail=False) @register_model_view(Contact, 'bulk_delete', path='delete', detail=False)
class ContactBulkDeleteView(generic.BulkDeleteView): class ContactBulkDeleteView(generic.BulkDeleteView):

View File

@ -144,8 +144,8 @@ class BaseFilterSetTests:
# Check that the filter class is correct # Check that the filter class is correct
filter = filters[filter_name] filter = filters[filter_name]
if filter_class is not None: if filter_class is not None:
self.assertIs( self.assertIsInstance(
type(filter), filter,
filter_class, filter_class,
f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!" f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!"
) )

View File

@ -26,7 +26,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer):
model = WirelessLANGroup model = WirelessLANGroup
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'wirelesslan_count', '_depth', 'created', 'last_updated', 'wirelesslan_count', 'comments', '_depth',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth')

View File

@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices
from dcim.base_filtersets import ScopedFilterSet from dcim.base_filtersets import ScopedFilterSet
from dcim.models import Interface from dcim.models import Interface
from ipam.models import VLAN from ipam.models import VLAN
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import * from .choices import *
@ -18,7 +18,7 @@ __all__ = (
) )
class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): class WirelessLANGroupFilterSet(NestedGroupModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all() queryset=WirelessLANGroup.objects.all()
) )

View File

@ -32,12 +32,13 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField()
model = WirelessLANGroup model = WirelessLANGroup
fieldsets = ( fieldsets = (
FieldSet('parent', 'description'), FieldSet('parent', 'description'),
) )
nullable_fields = ('parent', 'description') nullable_fields = ('parent', 'description', 'comments')
class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):

View File

@ -30,7 +30,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = WirelessLANGroup model = WirelessLANGroup
fields = ('name', 'slug', 'parent', 'description', 'tags') fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm):

View File

@ -24,6 +24,7 @@ class WirelessLANGroupForm(NetBoxModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')), FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')),
@ -32,7 +33,7 @@ class WirelessLANGroupForm(NetBoxModelForm):
class Meta: class Meta:
model = WirelessLANGroup model = WirelessLANGroup
fields = [ fields = [
'parent', 'name', 'slug', 'description', 'tags', 'parent', 'name', 'slug', 'description', 'tags', 'comments',
] ]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wireless', '0013_natural_ordering'),
]
operations = [
migrations.AddField(
model_name='wirelesslangroup',
name='comments',
field=models.TextField(blank=True),
),
]

View File

@ -21,6 +21,7 @@ class WirelessLANGroupIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('description',) display_attrs = ('description',)

View File

@ -24,10 +24,12 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Wireless LAN Group 4', 'name': 'Wireless LAN Group 4',
'slug': 'wireless-lan-group-4', 'slug': 'wireless-lan-group-4',
'comments': '',
}, },
{ {
'name': 'Wireless LAN Group 5', 'name': 'Wireless LAN Group 5',
'slug': 'wireless-lan-group-5', 'slug': 'wireless-lan-group-5',
'comments': 'LAN Group 5 comment',
}, },
{ {
'name': 'Wireless LAN Group 6', 'name': 'Wireless LAN Group 6',
@ -36,6 +38,7 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase):
] ]
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
'comments': 'New comment',
} }
@classmethod @classmethod

View File

@ -21,7 +21,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
parent_groups = ( parent_groups = (
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'),
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'),
WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), WirelessLANGroup(
name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C',
comments='Parent Group 3 comment',
),
) )
for group in parent_groups: for group in parent_groups:
group.save() group.save()
@ -38,10 +41,15 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
slug='wireless-lan-group-1b', slug='wireless-lan-group-1b',
parent=parent_groups[0], parent=parent_groups[0],
description='foobar2', description='foobar2',
comments='Child Group 1B comment',
), ),
WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]), WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]),
WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]), WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]),
WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2]), WirelessLANGroup(
name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2],
comments='Wireless LAN Group 3A comment',
),
WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=parent_groups[2]), WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=parent_groups[2]),
) )
for group in groups: for group in groups:
@ -62,6 +70,13 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_q_comments(self):
params = {'q': 'parent'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'q': 'comment'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_name(self): def test_name(self):
params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']} params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -16,7 +16,9 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
groups = ( groups = (
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'),
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), WirelessLANGroup(
name='Wireless LAN Group 2', slug='wireless-lan-group-2', comments='LAN Group 2 comment',
),
WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'),
) )
for group in groups: for group in groups:
@ -30,24 +32,26 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'parent': groups[2].pk, 'parent': groups[2].pk,
'description': 'A new wireless LAN group', 'description': 'A new wireless LAN group',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
'comments': 'LAN Group X comment',
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,description", "name,slug,description,comments",
"Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group,",
"Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group,",
"Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group,LAN Group 6 comment",
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,description", "id,name,description,comments",
f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7", f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7,Group 7 comment",
f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8", f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8,",
f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9", f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9,",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'comments': 'New Comments',
} }