diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py
index a3978f16e..de2c170a3 100644
--- a/netbox/netbox/navigation_menu.py
+++ b/netbox/netbox/navigation_menu.py
@@ -120,6 +120,14 @@ ORGANIZATION_MENU = Menu(
get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'),
),
),
+ MenuGroup(
+ label='Contacts',
+ items=(
+ get_model_item('tenancy', 'contact', 'Contacts'),
+ get_model_item('tenancy', 'contactgroup', 'Contact Groups'),
+ get_model_item('tenancy', 'contactrole', 'Contact Roles'),
+ ),
+ ),
),
)
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html
new file mode 100644
index 000000000..2ead52e5a
--- /dev/null
+++ b/netbox/templates/tenancy/contact.html
@@ -0,0 +1,66 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+ {% if object.group %}
+
{{ object.group }}
+ {% endif %}
+{% endblock breadcrumbs %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Group |
+
+ {% if object.group %}
+ {{ object.group }}
+ {% else %}
+ None
+ {% endif %}
+ |
+
+
+ Name |
+ {{ object.name }} |
+
+
+ Title |
+ {{ object.tile|placeholder }} |
+
+
+ Phone |
+ {{ object.phone|placeholder }} |
+
+
+ Email |
+ {{ object.email|placeholder }} |
+
+
+ Address |
+ {{ object.address|linebreaksbr|placeholder }} |
+
+
+
+
+ {% include 'inc/comments_panel.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html
new file mode 100644
index 000000000..1511565c3
--- /dev/null
+++ b/netbox/templates/tenancy/contactgroup.html
@@ -0,0 +1,76 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+ {% for contactgroup in object.get_ancestors %}
+ {{ contactgroup }}
+ {% endfor %}
+{% endblock %}
+
+{% block content %}
+
+
+
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+
+ {% include 'inc/table.html' with table=contacts_table %}
+
+ {% if perms.tenancy.add_contact %}
+
+ {% endif %}
+
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html
new file mode 100644
index 000000000..688c58177
--- /dev/null
+++ b/netbox/templates/tenancy/contactrole.html
@@ -0,0 +1,46 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+ Contact Roles
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Name |
+ {{ object.name }} |
+
+
+ Description |
+ {{ object.description|placeholder }} |
+
+
+
+
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+
+ {% include 'inc/table.html' with table=contacts_table %}
+
+
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py
index 11225fa7a..a072331f5 100644
--- a/netbox/tenancy/api/nested_serializers.py
+++ b/netbox/tenancy/api/nested_serializers.py
@@ -1,9 +1,12 @@
from rest_framework import serializers
from netbox.api import WritableNestedSerializer
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
__all__ = [
+ 'NestedContactSerializer',
+ 'NestedContactGroupSerializer',
+ 'NestedContactRoleSerializer',
'NestedTenantGroupSerializer',
'NestedTenantSerializer',
]
@@ -29,3 +32,33 @@ class NestedTenantSerializer(WritableNestedSerializer):
class Meta:
model = Tenant
fields = ['id', 'url', 'display', 'name', 'slug']
+
+
+#
+# Contacts
+#
+
+class NestedContactGroupSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
+ contact_count = serializers.IntegerField(read_only=True)
+ _depth = serializers.IntegerField(source='level', read_only=True)
+
+ class Meta:
+ model = ContactGroup
+ fields = ['id', 'url', 'display', 'name', 'slug', 'contact_count', '_depth']
+
+
+class NestedContactRoleSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
+
+ class Meta:
+ model = ContactRole
+ fields = ['id', 'url', 'display', 'name', 'slug']
+
+
+class NestedContactSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
+
+ class Meta:
+ model = Contact
+ fields = ['id', 'url', 'display', 'name']
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 3136c811c..2dfb59455 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -1,7 +1,9 @@
+from django.contrib.auth.models import ContentType
from rest_framework import serializers
-from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
-from tenancy.models import Tenant, TenantGroup
+from netbox.api import ContentTypeField
+from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer
+from tenancy.models import *
from .nested_serializers import *
@@ -43,3 +45,57 @@ class TenantSerializer(PrimaryModelSerializer):
'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
]
+
+
+#
+# Contacts
+#
+
+class ContactGroupSerializer(NestedGroupModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
+ parent = NestedContactGroupSerializer(required=False, allow_null=True)
+ contact_count = serializers.IntegerField(read_only=True)
+
+ class Meta:
+ model = ContactGroup
+ fields = [
+ 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
+ 'contact_count', '_depth',
+ ]
+
+
+class ContactRoleSerializer(OrganizationalModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
+
+ class Meta:
+ model = ContactRole
+ fields = [
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+ ]
+
+
+class ContactSerializer(PrimaryModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
+ group = NestedContactGroupSerializer(required=False, allow_null=True)
+
+ class Meta:
+ model = Contact
+ fields = [
+ 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated',
+ ]
+
+
+class ContactAssignmentSerializer(PrimaryModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
+ content_type = ContentTypeField(
+ queryset=ContentType.objects.all()
+ )
+ contact = NestedContactSerializer()
+ role = NestedContactRoleSerializer(required=False, allow_null=True)
+
+ class Meta:
+ model = ContactAssignment
+ fields = [
+ 'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'created', 'last_updated',
+ ]
diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py
index 32540879d..00e1a6469 100644
--- a/netbox/tenancy/api/urls.py
+++ b/netbox/tenancy/api/urls.py
@@ -9,5 +9,11 @@ router.APIRootView = views.TenancyRootView
router.register('tenant-groups', views.TenantGroupViewSet)
router.register('tenants', views.TenantViewSet)
+# Contacts
+router.register('contact-groups', views.ContactGroupViewSet)
+router.register('contact-roles', views.ContactRoleViewSet)
+router.register('contacts', views.ContactViewSet)
+router.register('contact-assignments', views.ContactAssignmentViewSet)
+
app_name = 'tenancy-api'
urlpatterns = router.urls
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index 2e049135d..7ce16c143 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -5,7 +5,7 @@ from dcim.models import Device, Rack, Site
from extras.api.views import CustomFieldModelViewSet
from ipam.models import IPAddress, Prefix, VLAN, VRF
from tenancy import filtersets
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
@@ -20,7 +20,7 @@ class TenancyRootView(APIRootView):
#
-# Tenant Groups
+# Tenants
#
class TenantGroupViewSet(CustomFieldModelViewSet):
@@ -35,10 +35,6 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.TenantGroupFilterSet
-#
-# Tenants
-#
-
class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.prefetch_related(
'group', 'tags'
@@ -55,3 +51,41 @@ class TenantViewSet(CustomFieldModelViewSet):
)
serializer_class = serializers.TenantSerializer
filterset_class = filtersets.TenantFilterSet
+
+
+#
+# Contacts
+#
+
+class ContactGroupViewSet(CustomFieldModelViewSet):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ serializer_class = serializers.ContactGroupSerializer
+ filterset_class = filtersets.ContactGroupFilterSet
+
+
+class ContactRoleViewSet(CustomFieldModelViewSet):
+ queryset = ContactRole.objects.all()
+ serializer_class = serializers.ContactRoleSerializer
+ filterset_class = filtersets.ContactRoleFilterSet
+
+
+class ContactViewSet(CustomFieldModelViewSet):
+ queryset = Contact.objects.prefetch_related(
+ 'group', 'tags'
+ )
+ serializer_class = serializers.ContactSerializer
+ filterset_class = filtersets.ContactFilterSet
+
+
+class ContactAssignmentViewSet(CustomFieldModelViewSet):
+ queryset = ContactAssignment.objects.prefetch_related(
+ 'contact', 'role'
+ )
+ serializer_class = serializers.ContactAssignmentSerializer
+ filterset_class = filtersets.ContactAssignmentFilterSet
diff --git a/netbox/tenancy/choices.py b/netbox/tenancy/choices.py
new file mode 100644
index 000000000..b59d2050d
--- /dev/null
+++ b/netbox/tenancy/choices.py
@@ -0,0 +1,19 @@
+from utilities.choices import ChoiceSet
+
+
+#
+# Contacts
+#
+
+class ContactPriorityChoices(ChoiceSet):
+ PRIORITY_PRIMARY = 'primary'
+ PRIORITY_SECONDARY = 'secondary'
+ PRIORITY_TERTIARY = 'tertiary'
+ PRIORITY_INACTIVE = 'inactive'
+
+ CHOICES = (
+ (PRIORITY_PRIMARY, 'Primary'),
+ (PRIORITY_SECONDARY, 'Secondary'),
+ (PRIORITY_TERTIARY, 'Tertiary'),
+ (PRIORITY_INACTIVE, 'Inactive'),
+ )
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index d00b78629..75f9e351d 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -4,16 +4,24 @@ from django.db.models import Q
from extras.filters import TagFilter
from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
-from .models import Tenant, TenantGroup
+from .models import *
__all__ = (
+ 'ContactAssignmentFilterSet',
+ 'ContactFilterSet',
+ 'ContactGroupFilterSet',
+ 'ContactRoleFilterSet',
'TenancyFilterSet',
'TenantFilterSet',
'TenantGroupFilterSet',
)
+#
+# Tenancy
+#
+
class TenantGroupFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
@@ -23,7 +31,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet):
field_name='parent__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
- label='Tenant group group (slug)',
+ label='Tenant group (slug)',
)
class Meta:
@@ -93,3 +101,89 @@ class TenancyFilterSet(django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
+
+
+#
+# Contacts
+#
+
+class ContactGroupFilterSet(OrganizationalModelFilterSet):
+ parent_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ label='Contact group (ID)',
+ )
+ parent = django_filters.ModelMultipleChoiceFilter(
+ field_name='parent__slug',
+ queryset=ContactGroup.objects.all(),
+ to_field_name='slug',
+ label='Contact group (slug)',
+ )
+
+ class Meta:
+ model = ContactGroup
+ fields = ['id', 'name', 'slug', 'description']
+
+
+class ContactRoleFilterSet(OrganizationalModelFilterSet):
+
+ class Meta:
+ model = ContactRole
+ fields = ['id', 'name', 'slug']
+
+
+class ContactFilterSet(PrimaryModelFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
+ group_id = TreeNodeMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ field_name='group',
+ lookup_expr='in',
+ label='Contact group (ID)',
+ )
+ group = TreeNodeMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ field_name='group',
+ lookup_expr='in',
+ to_field_name='slug',
+ label='Contact group (slug)',
+ )
+ tag = TagFilter()
+
+ class Meta:
+ model = Contact
+ fields = ['id', 'name', 'title', 'phone', 'email', 'address']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(title__icontains=value) |
+ Q(phone__icontains=value) |
+ Q(email__icontains=value) |
+ Q(address__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class ContactAssignmentFilterSet(OrganizationalModelFilterSet):
+ contact_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Contact.objects.all(),
+ label='Contact (ID)',
+ )
+ role_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ContactRole.objects.all(),
+ label='Contact role (ID)',
+ )
+ role = django_filters.ModelMultipleChoiceFilter(
+ field_name='role__slug',
+ queryset=ContactRole.objects.all(),
+ to_field_name='slug',
+ label='Contact role (slug)',
+ )
+
+ class Meta:
+ model = ContactAssignment
+ fields = ['id', 'priority']
diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py
index b2fc7dafd..0d414d2a5 100644
--- a/netbox/tenancy/forms/bulk_edit.py
+++ b/netbox/tenancy/forms/bulk_edit.py
@@ -1,15 +1,22 @@
from django import forms
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import BootstrapMixin, DynamicModelChoiceField
__all__ = (
+ 'ContactBulkEditForm',
+ 'ContactGroupBulkEditForm',
+ 'ContactRoleBulkEditForm',
'TenantBulkEditForm',
'TenantGroupBulkEditForm',
)
+#
+# Tenants
+#
+
class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
@@ -42,3 +49,53 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
nullable_fields = [
'group',
]
+
+
+#
+# Contacts
+#
+
+class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ parent = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['parent', 'description']
+
+
+class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ContactRole.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['description']
+
+
+class ContactBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=Contact.objects.all(),
+ widget=forms.MultipleHiddenInput()
+ )
+ group = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments']
diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py
index 335d71ef6..73e152a29 100644
--- a/netbox/tenancy/forms/bulk_import.py
+++ b/netbox/tenancy/forms/bulk_import.py
@@ -1,13 +1,20 @@
from extras.forms import CustomFieldModelCSVForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import CSVModelChoiceField, SlugField
__all__ = (
+ 'ContactCSVForm',
+ 'ContactGroupCSVForm',
+ 'ContactRoleCSVForm',
'TenantCSVForm',
'TenantGroupCSVForm',
)
+#
+# Tenants
+#
+
class TenantGroupCSVForm(CustomFieldModelCSVForm):
parent = CSVModelChoiceField(
queryset=TenantGroup.objects.all(),
@@ -34,3 +41,43 @@ class TenantCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Tenant
fields = ('name', 'slug', 'group', 'description', 'comments')
+
+
+#
+# Contacts
+#
+
+class ContactGroupCSVForm(CustomFieldModelCSVForm):
+ parent = CSVModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Parent group'
+ )
+ slug = SlugField()
+
+ class Meta:
+ model = ContactGroup
+ fields = ('name', 'slug', 'parent', 'description')
+
+
+class ContactRoleCSVForm(CustomFieldModelCSVForm):
+ slug = SlugField()
+
+ class Meta:
+ model = ContactRole
+ fields = ('name', 'slug', 'description')
+
+
+class ContactCSVForm(CustomFieldModelCSVForm):
+ slug = SlugField()
+ group = CSVModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Assigned group'
+ )
+
+ class Meta:
+ model = Contact
+ fields = ('name', 'title', 'phone', 'email', 'address', 'group', 'comments')
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 6e2eb7fd1..69941701f 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -2,9 +2,21 @@ from django import forms
from django.utils.translation import gettext as _
from extras.forms import CustomFieldModelFilterForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField
+__all__ = (
+ 'ContactFilterForm',
+ 'ContactGroupFilterForm',
+ 'ContactRoleFilterForm',
+ 'TenantFilterForm',
+ 'TenantGroupFilterForm',
+)
+
+
+#
+# Tenants
+#
class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = TenantGroup
@@ -40,3 +52,55 @@ class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
fetch_trigger='open'
)
tag = TagFilterField(model)
+
+
+#
+# Contacts
+#
+
+class ContactGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+ model = ContactGroup
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ parent_id = DynamicModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ label=_('Parent group'),
+ fetch_trigger='open'
+ )
+
+
+class ContactRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+ model = ContactRole
+ field_groups = [
+ ['q'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+
+
+class ContactFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+ model = Contact
+ field_groups = (
+ ('q', 'tag'),
+ ('group_id',),
+ )
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ group_id = DynamicModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ null_option='None',
+ label=_('Group'),
+ fetch_trigger='open'
+ )
+ tag = TagFilterField(model)
diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py
index de3a9e515..6c0640d53 100644
--- a/netbox/tenancy/forms/models.py
+++ b/netbox/tenancy/forms/models.py
@@ -1,16 +1,23 @@
from extras.forms import CustomFieldModelForm
from extras.models import Tag
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import (
- BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
+ BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
)
__all__ = (
+ 'ContactForm',
+ 'ContactGroupForm',
+ 'ContactRoleForm',
'TenantForm',
'TenantGroupForm',
)
+#
+# Tenants
+#
+
class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
@@ -45,3 +52,51 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
fieldsets = (
('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
)
+
+
+#
+# Contacts
+#
+
+class ContactGroupForm(BootstrapMixin, CustomFieldModelForm):
+ parent = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ slug = SlugField()
+
+ class Meta:
+ model = ContactGroup
+ fields = ['parent', 'name', 'slug', 'description']
+
+
+class ContactRoleForm(BootstrapMixin, CustomFieldModelForm):
+ slug = SlugField()
+
+ class Meta:
+ model = ContactRole
+ fields = ['name', 'slug', 'description']
+
+
+class ContactForm(BootstrapMixin, CustomFieldModelForm):
+ group = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ comments = CommentField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ model = Contact
+ fields = (
+ 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
+ )
+ fieldsets = (
+ ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')),
+ )
+ widgets = {
+ 'address': SmallTextarea(attrs={'rows': 3}),
+ }
diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py
index f420eb787..de0a1781a 100644
--- a/netbox/tenancy/graphql/schema.py
+++ b/netbox/tenancy/graphql/schema.py
@@ -10,3 +10,15 @@ class TenancyQuery(graphene.ObjectType):
tenant_group = ObjectField(TenantGroupType)
tenant_group_list = ObjectListField(TenantGroupType)
+
+ contact = ObjectField(ContactType)
+ contact_list = ObjectListField(ContactType)
+
+ contact_role = ObjectField(ContactRoleType)
+ contact_role_list = ObjectListField(ContactRoleType)
+
+ contact_group = ObjectField(ContactGroupType)
+ contact_group_list = ObjectListField(ContactGroupType)
+
+ contact_assignment = ObjectField(ContactAssignmentType)
+ contact_assignment_list = ObjectListField(ContactAssignmentType)
diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py
index 6f1e27274..a16d51081 100644
--- a/netbox/tenancy/graphql/types.py
+++ b/netbox/tenancy/graphql/types.py
@@ -1,12 +1,29 @@
+import graphene
+
from tenancy import filtersets, models
from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
__all__ = (
+ 'ContactAssignmentType',
+ 'ContactGroupType',
+ 'ContactRoleType',
+ 'ContactType',
'TenantType',
'TenantGroupType',
)
+class ContactAssignmentsMixin:
+ assignments = graphene.List('tenancy.graphql.types.ContactAssignmentType')
+
+ def resolve_assignments(self, info):
+ return self.assignments.restrict(info.context.user, 'view')
+
+
+#
+# Tenants
+#
+
class TenantType(PrimaryObjectType):
class Meta:
@@ -21,3 +38,39 @@ class TenantGroupType(OrganizationalObjectType):
model = models.TenantGroup
fields = '__all__'
filterset_class = filtersets.TenantGroupFilterSet
+
+
+#
+# Contacts
+#
+
+class ContactType(ContactAssignmentsMixin, PrimaryObjectType):
+
+ class Meta:
+ model = models.Contact
+ fields = '__all__'
+ filterset_class = filtersets.ContactFilterSet
+
+
+class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType):
+
+ class Meta:
+ model = models.ContactRole
+ fields = '__all__'
+ filterset_class = filtersets.ContactRoleFilterSet
+
+
+class ContactGroupType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.ContactGroup
+ fields = '__all__'
+ filterset_class = filtersets.ContactGroupFilterSet
+
+
+class ContactAssignmentType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.ContactAssignment
+ fields = '__all__'
+ filterset_class = filtersets.ContactAssignmentFilterSet
diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py
new file mode 100644
index 000000000..dc6f6c668
--- /dev/null
+++ b/netbox/tenancy/migrations/0003_contacts.py
@@ -0,0 +1,98 @@
+# Generated by Django 3.2.8 on 2021-10-18 16:12
+
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import mptt.fields
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0062_clear_secrets_changelog'),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('tenancy', '0002_tenant_ordering'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Contact',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100)),
+ ('title', models.CharField(blank=True, max_length=100)),
+ ('phone', models.CharField(blank=True, max_length=50)),
+ ('email', models.EmailField(blank=True, max_length=254)),
+ ('address', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ContactRole',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('slug', models.SlugField(max_length=100, unique=True)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ContactGroup',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('slug', models.SlugField(max_length=100, unique=True)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('lft', models.PositiveIntegerField(editable=False)),
+ ('rght', models.PositiveIntegerField(editable=False)),
+ ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
+ ('level', models.PositiveIntegerField(editable=False)),
+ ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.contactgroup')),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ContactAssignment',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('object_id', models.PositiveIntegerField()),
+ ('priority', models.CharField(blank=True, max_length=50)),
+ ('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contact')),
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+ ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contactrole')),
+ ],
+ options={
+ 'ordering': ('priority', 'contact'),
+ },
+ ),
+ migrations.AddField(
+ model_name='contact',
+ name='group',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup'),
+ ),
+ migrations.AddField(
+ model_name='contact',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ ]
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index 4a5b1967e..f5e66b753 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -1,19 +1,29 @@
-from django.core.exceptions import ValidationError
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from extras.utils import extras_features
-from netbox.models import NestedGroupModel, PrimaryModel
+from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
+from .choices import *
__all__ = (
+ 'ContactAssignment',
+ 'Contact',
+ 'ContactGroup',
+ 'ContactRole',
'Tenant',
'TenantGroup',
)
+#
+# Tenants
+#
+
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class TenantGroup(NestedGroupModel):
"""
@@ -90,3 +100,153 @@ class Tenant(PrimaryModel):
def get_absolute_url(self):
return reverse('tenancy:tenant', args=[self.pk])
+
+
+#
+# Contacts
+#
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class ContactGroup(NestedGroupModel):
+ """
+ An arbitrary collection of Contacts.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ slug = models.SlugField(
+ max_length=100,
+ unique=True
+ )
+ parent = TreeForeignKey(
+ to='self',
+ on_delete=models.CASCADE,
+ related_name='children',
+ blank=True,
+ null=True,
+ db_index=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+
+ class Meta:
+ ordering = ['name']
+
+ def get_absolute_url(self):
+ return reverse('tenancy:contactgroup', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class ContactRole(OrganizationalModel):
+ """
+ Functional role for a Contact assigned to an object.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ slug = models.SlugField(
+ max_length=100,
+ unique=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True,
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('tenancy:contactrole', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class Contact(PrimaryModel):
+ """
+ Contact information for a particular object(s) in NetBox.
+ """
+ group = models.ForeignKey(
+ to='tenancy.ContactGroup',
+ on_delete=models.SET_NULL,
+ related_name='contacts',
+ blank=True,
+ null=True
+ )
+ name = models.CharField(
+ max_length=100
+ )
+ title = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ phone = models.CharField(
+ max_length=50,
+ blank=True
+ )
+ email = models.EmailField(
+ blank=True
+ )
+ address = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ comments = models.TextField(
+ blank=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ clone_fields = [
+ 'group',
+ ]
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('tenancy:contact', args=[self.pk])
+
+
+@extras_features('webhooks')
+class ContactAssignment(ChangeLoggedModel):
+ content_type = models.ForeignKey(
+ to=ContentType,
+ on_delete=models.CASCADE
+ )
+ object_id = models.PositiveIntegerField()
+ object = GenericForeignKey(
+ ct_field='content_type',
+ fk_field='object_id'
+ )
+ contact = models.ForeignKey(
+ to='tenancy.Contact',
+ on_delete=models.PROTECT,
+ related_name='assignments'
+ )
+ role = models.ForeignKey(
+ to='tenancy.ContactRole',
+ on_delete=models.PROTECT,
+ related_name='assignments'
+ )
+ priority = models.CharField(
+ max_length=50,
+ choices=ContactPriorityChoices,
+ blank=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ('priority', 'contact')
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py
index f39ca1b18..3401c8fe4 100644
--- a/netbox/tenancy/tables.py
+++ b/netbox/tenancy/tables.py
@@ -3,9 +3,13 @@ import django_tables2 as tables
from utilities.tables import (
BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
)
-from .models import Tenant, TenantGroup
+from .models import *
__all__ = (
+ 'ContactAssignmentTable',
+ 'ContactGroupTable',
+ 'ContactRoleTable',
+ 'ContactTable',
'TenantColumn',
'TenantGroupTable',
'TenantTable',
@@ -38,7 +42,7 @@ class TenantColumn(tables.TemplateColumn):
#
-# Tenant groups
+# Tenants
#
class TenantGroupTable(BaseTable):
@@ -59,10 +63,6 @@ class TenantGroupTable(BaseTable):
default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
-#
-# Tenants
-#
-
class TenantTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
@@ -80,3 +80,69 @@ class TenantTable(BaseTable):
model = Tenant
fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'group', 'description')
+
+
+#
+# Contacts
+#
+
+class ContactGroupTable(BaseTable):
+ pk = ToggleColumn()
+ name = MPTTColumn(
+ linkify=True
+ )
+ contact_count = LinkedCountColumn(
+ viewname='tenancy:contact_list',
+ url_params={'role_id': 'pk'},
+ verbose_name='Contacts'
+ )
+ actions = ButtonsColumn(ContactGroup)
+
+ class Meta(BaseTable.Meta):
+ model = ContactGroup
+ fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions')
+ default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
+
+
+class ContactRoleTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ actions = ButtonsColumn(ContactRole)
+
+ class Meta(BaseTable.Meta):
+ model = ContactRole
+ fields = ('pk', 'name', 'description', 'slug', 'actions')
+ default_columns = ('pk', 'name', 'description', 'actions')
+
+
+class ContactTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ group = tables.Column(
+ linkify=True
+ )
+ comments = MarkdownColumn()
+ tags = TagColumn(
+ url_name='tenancy:tenant_list'
+ )
+
+ class Meta(BaseTable.Meta):
+ model = Contact
+ fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'tags')
+ default_columns = ('pk', 'name', 'group', 'title', 'phone', 'email')
+
+
+class ContactAssignmentTable(BaseTable):
+ pk = ToggleColumn()
+ contact = tables.Column(
+ linkify=True
+ )
+
+ class Meta(BaseTable.Meta):
+ model = ContactAssignment
+ fields = ('pk', 'contact', 'role', 'priority')
+ default_columns = ('pk', 'contact', 'role', 'priority')
diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py
index 5a3c2c1b0..c7c6cf846 100644
--- a/netbox/tenancy/tests/test_api.py
+++ b/netbox/tenancy/tests/test_api.py
@@ -1,6 +1,6 @@
from django.urls import reverse
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.testing import APITestCase, APIViewTestCases
@@ -92,3 +92,112 @@ class TenantTest(APIViewTestCases.APIViewTestCase):
'group': tenant_groups[1].pk,
},
]
+
+
+class ContactGroupTest(APIViewTestCases.APIViewTestCase):
+ model = ContactGroup
+ brief_fields = ['_depth', 'contact_count', 'display', 'id', 'name', 'slug', 'url']
+ bulk_update_data = {
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ parent_contact_groups = (
+ ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'),
+ 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 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])
+
+ cls.create_data = [
+ {
+ 'name': 'Contact Group 4',
+ 'slug': 'contact-group-4',
+ 'parent': parent_contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact Group 5',
+ 'slug': 'contact-group-5',
+ 'parent': parent_contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact Group 6',
+ 'slug': 'contact-group-6',
+ 'parent': parent_contact_groups[1].pk,
+ },
+ ]
+
+
+class ContactRoleTest(APIViewTestCases.APIViewTestCase):
+ model = ContactRole
+ brief_fields = ['display', 'id', 'name', 'slug', 'url']
+ create_data = [
+ {
+ 'name': 'Contact Role 4',
+ 'slug': 'contact-role-4',
+ },
+ {
+ 'name': 'Contact Role 5',
+ 'slug': 'contact-role-5',
+ },
+ {
+ 'name': 'Contact Role 6',
+ 'slug': 'contact-role-6',
+ },
+ ]
+ bulk_update_data = {
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_roles = (
+ ContactRole(name='Contact Role 1', slug='contact-role-1'),
+ ContactRole(name='Contact Role 2', slug='contact-role-2'),
+ ContactRole(name='Contact Role 3', slug='contact-role-3'),
+ )
+ ContactRole.objects.bulk_create(contact_roles)
+
+
+class ContactTest(APIViewTestCases.APIViewTestCase):
+ model = Contact
+ brief_fields = ['display', 'id', 'name', 'url']
+ bulk_update_data = {
+ 'group': None,
+ 'comments': 'New comments',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2'),
+ )
+
+ contacts = (
+ Contact(name='Contact 1', group=contact_groups[0]),
+ Contact(name='Contact 2', group=contact_groups[0]),
+ Contact(name='Contact 3', group=contact_groups[0]),
+ )
+ Contact.objects.bulk_create(contacts)
+
+ cls.create_data = [
+ {
+ 'name': 'Contact 4',
+ 'group': contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact 5',
+ 'group': contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact 6',
+ 'group': contact_groups[1].pk,
+ },
+ ]
diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py
index fd4a0bd76..86170734c 100644
--- a/netbox/tenancy/tests/test_filtersets.py
+++ b/netbox/tenancy/tests/test_filtersets.py
@@ -1,7 +1,7 @@
from django.test import TestCase
from tenancy.filtersets import *
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.testing import ChangeLoggedFilterSetTests
@@ -84,3 +84,103 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [group[0].slug, group[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = ContactGroup.objects.all()
+ filterset = ContactGroupFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ parent_contact_groups = (
+ ContactGroup(name='Parent Contact Group 1', slug='parent-contact-group-1'),
+ ContactGroup(name='Parent Contact Group 2', slug='parent-contact-group-2'),
+ ContactGroup(name='Parent Contact Group 3', slug='parent-contact-group-3'),
+ )
+ for contactgroup in parent_contact_groups:
+ contactgroup.save()
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0], description='A'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[1], description='B'),
+ ContactGroup(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[2], description='C'),
+ )
+ for contactgroup in contact_groups:
+ contactgroup.save()
+
+ def test_name(self):
+ params = {'name': ['Contact Group 1', 'Contact Group 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_slug(self):
+ params = {'slug': ['contact-group-1', 'contact-group-2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['A', 'B']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_parent(self):
+ parent_groups = ContactGroup.objects.filter(parent__isnull=True)[:2]
+ params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = ContactRole.objects.all()
+ filterset = ContactRoleFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_roles = (
+ ContactRole(name='Contact Role 1', slug='contact-role-1'),
+ ContactRole(name='Contact Role 2', slug='contact-role-2'),
+ ContactRole(name='Contact Role 3', slug='contact-role-3'),
+ )
+ ContactRole.objects.bulk_create(contact_roles)
+
+ def test_name(self):
+ params = {'name': ['Contact Role 1', 'Contact Role 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_slug(self):
+ params = {'slug': ['contact-role-1', 'contact-role-2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = Contact.objects.all()
+ filterset = ContactFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2'),
+ ContactGroup(name='Contact Group 3', slug='contact-group-3'),
+ )
+ for contactgroup in contact_groups:
+ contactgroup.save()
+
+ contacts = (
+ Contact(name='Contact 1', group=contact_groups[0]),
+ Contact(name='Contact 2', group=contact_groups[1]),
+ Contact(name='Contact 3', group=contact_groups[2]),
+ )
+ Contact.objects.bulk_create(contacts)
+
+ def test_name(self):
+ params = {'name': ['Contact 1', 'Contact 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_group(self):
+ group = ContactGroup.objects.all()[:2]
+ params = {'group_id': [group[0].pk, group[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'group': [group[0].slug, group[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py
index f45afc302..fb7ff3ce3 100644
--- a/netbox/tenancy/tests/test_views.py
+++ b/netbox/tenancy/tests/test_views.py
@@ -1,4 +1,4 @@
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.testing import ViewTestCases, create_tags
@@ -74,3 +74,105 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'group': tenant_groups[1].pk,
}
+
+
+class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+ model = ContactGroup
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2'),
+ ContactGroup(name='Contact Group 3', slug='contact-group-3'),
+ )
+ for tenanantgroup in contact_groups:
+ tenanantgroup.save()
+
+ cls.form_data = {
+ 'name': 'Contact Group X',
+ 'slug': 'contact-group-x',
+ 'description': 'A new contact group',
+ }
+
+ cls.csv_data = (
+ "name,slug,description",
+ "Contact Group 4,contact-group-4,Fourth contact group",
+ "Contact Group 5,contact-group-5,Fifth contact group",
+ "Contact Group 6,contact-group-6,Sixth contact group",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ }
+
+
+class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+ model = ContactRole
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ContactRole.objects.bulk_create([
+ ContactRole(name='Contact Role 1', slug='contact-role-1'),
+ ContactRole(name='Contact Role 2', slug='contact-role-2'),
+ ContactRole(name='Contact Role 3', slug='contact-role-3'),
+ ])
+
+ cls.form_data = {
+ 'name': 'Devie Role X',
+ 'slug': 'contact-role-x',
+ 'description': 'New contact role',
+ }
+
+ cls.csv_data = (
+ "name,slug",
+ "Contact Role 4,contact-role-4",
+ "Contact Role 5,contact-role-5",
+ "Contact Role 6,contact-role-6",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ }
+
+
+class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = Contact
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2'),
+ )
+ for contactgroup in contact_groups:
+ contactgroup.save()
+
+ Contact.objects.bulk_create([
+ Contact(name='Contact 1', group=contact_groups[0]),
+ Contact(name='Contact 2', group=contact_groups[0]),
+ Contact(name='Contact 3', group=contact_groups[0]),
+ ])
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'Contact X',
+ 'group': contact_groups[1].pk,
+ 'comments': 'Some comments',
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,slug",
+ "Contact 4,contact-4",
+ "Contact 5,contact-5",
+ "Contact 6,contact-6",
+ )
+
+ cls.bulk_edit_data = {
+ 'group': contact_groups[1].pk,
+ }
diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py
index a1f46c7ec..807af161e 100644
--- a/netbox/tenancy/urls.py
+++ b/netbox/tenancy/urls.py
@@ -3,7 +3,7 @@ from django.urls import path
from extras.views import ObjectChangeLogView, ObjectJournalView
from utilities.views import SlugRedirectView
from . import views
-from .models import Tenant, TenantGroup
+from .models import *
app_name = 'tenancy'
urlpatterns = [
@@ -32,4 +32,39 @@ urlpatterns = [
path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}),
+ # Contact groups
+ path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'),
+ path('contact-groups/add/', views.ContactGroupEditView.as_view(), name='contactgroup_add'),
+ path('contact-groups/import/', views.ContactGroupBulkImportView.as_view(), name='contactgroup_import'),
+ path('contact-groups/edit/', views.ContactGroupBulkEditView.as_view(), name='contactgroup_bulk_edit'),
+ path('contact-groups/delete/', views.ContactGroupBulkDeleteView.as_view(), name='contactgroup_bulk_delete'),
+ path('contact-groups//', views.ContactGroupView.as_view(), name='contactgroup'),
+ path('contact-groups//edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'),
+ path('contact-groups//delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'),
+ path('contact-groups//changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}),
+
+ # Contact roles
+ path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'),
+ path('contact-roles/add/', views.ContactRoleEditView.as_view(), name='contactrole_add'),
+ path('contact-roles/import/', views.ContactRoleBulkImportView.as_view(), name='contactrole_import'),
+ path('contact-roles/edit/', views.ContactRoleBulkEditView.as_view(), name='contactrole_bulk_edit'),
+ path('contact-roles/delete/', views.ContactRoleBulkDeleteView.as_view(), name='contactrole_bulk_delete'),
+ path('contact-roles//', views.ContactRoleView.as_view(), name='contactrole'),
+ path('contact-roles//edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'),
+ path('contact-roles//delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'),
+ path('contact-roles//changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}),
+
+ # Contacts
+ path('contacts/', views.ContactListView.as_view(), name='contact_list'),
+ path('contacts/add/', views.ContactEditView.as_view(), name='contact_add'),
+ path('contacts/import/', views.ContactBulkImportView.as_view(), name='contact_import'),
+ path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'),
+ path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'),
+ path('contacts//', views.ContactView.as_view(), name='contact'),
+ path('contacts//', SlugRedirectView.as_view(), kwargs={'model': Contact}),
+ path('contacts//edit/', views.ContactEditView.as_view(), name='contact_edit'),
+ path('contacts//delete/', views.ContactDeleteView.as_view(), name='contact_delete'),
+ path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
+ path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}),
+
]
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 0b28a62d2..f4772b288 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -3,9 +3,10 @@ from dcim.models import Site, Rack, Device, RackReservation
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from netbox.views import generic
from utilities.tables import paginate_table
+from utilities.utils import count_related
from virtualization.models import VirtualMachine, Cluster
from . import filtersets, forms, tables
-from .models import Tenant, TenantGroup
+from .models import *
#
@@ -140,3 +141,171 @@ class TenantBulkDeleteView(generic.BulkDeleteView):
queryset = Tenant.objects.prefetch_related('group')
filterset = filtersets.TenantFilterSet
table = tables.TenantTable
+
+
+#
+# Contact groups
+#
+
+class ContactGroupListView(generic.ObjectListView):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ filterset = filtersets.ContactGroupFilterSet
+ filterset_form = forms.ContactGroupFilterForm
+ table = tables.ContactGroupTable
+
+
+class ContactGroupView(generic.ObjectView):
+ queryset = ContactGroup.objects.all()
+
+ def get_extra_context(self, request, instance):
+ contacts = Contact.objects.restrict(request.user, 'view').filter(
+ group=instance
+ )
+ contacts_table = tables.ContactTable(contacts, exclude=('group',))
+ paginate_table(contacts_table, request)
+
+ return {
+ 'contacts_table': contacts_table,
+ }
+
+
+class ContactGroupEditView(generic.ObjectEditView):
+ queryset = ContactGroup.objects.all()
+ model_form = forms.ContactGroupForm
+
+
+class ContactGroupDeleteView(generic.ObjectDeleteView):
+ queryset = ContactGroup.objects.all()
+
+
+class ContactGroupBulkImportView(generic.BulkImportView):
+ queryset = ContactGroup.objects.all()
+ model_form = forms.ContactGroupCSVForm
+ table = tables.ContactGroupTable
+
+
+class ContactGroupBulkEditView(generic.BulkEditView):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ filterset = filtersets.ContactGroupFilterSet
+ table = tables.ContactGroupTable
+ form = forms.ContactGroupBulkEditForm
+
+
+class ContactGroupBulkDeleteView(generic.BulkDeleteView):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ table = tables.ContactGroupTable
+
+
+#
+# Contact roles
+#
+
+class ContactRoleListView(generic.ObjectListView):
+ queryset = ContactRole.objects.all()
+ filterset = filtersets.ContactRoleFilterSet
+ filterset_form = forms.ContactRoleFilterForm
+ table = tables.ContactRoleTable
+
+
+class ContactRoleView(generic.ObjectView):
+ queryset = ContactRole.objects.all()
+
+ def get_extra_context(self, request, instance):
+ contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
+ role=instance
+ )
+ contacts_table = tables.ContactAssignmentTable(contact_assignments)
+ paginate_table(contacts_table, request)
+
+ return {
+ 'contacts_table': contacts_table,
+ 'contact_count': ContactAssignment.objects.filter(role=instance).count(),
+ }
+
+
+class ContactRoleEditView(generic.ObjectEditView):
+ queryset = ContactRole.objects.all()
+ model_form = forms.ContactRoleForm
+
+
+class ContactRoleDeleteView(generic.ObjectDeleteView):
+ queryset = ContactRole.objects.all()
+
+
+class ContactRoleBulkImportView(generic.BulkImportView):
+ queryset = ContactRole.objects.all()
+ model_form = forms.ContactRoleCSVForm
+ table = tables.ContactRoleTable
+
+
+class ContactRoleBulkEditView(generic.BulkEditView):
+ queryset = ContactRole.objects.all()
+ filterset = filtersets.ContactRoleFilterSet
+ table = tables.ContactRoleTable
+ form = forms.ContactRoleBulkEditForm
+
+
+class ContactRoleBulkDeleteView(generic.BulkDeleteView):
+ queryset = ContactRole.objects.all()
+ table = tables.ContactRoleTable
+
+
+#
+# Contacts
+#
+
+class ContactListView(generic.ObjectListView):
+ queryset = Contact.objects.all()
+ filterset = filtersets.ContactFilterSet
+ filterset_form = forms.ContactFilterForm
+ table = tables.ContactTable
+
+
+class ContactView(generic.ObjectView):
+ queryset = Contact.objects.all()
+
+
+class ContactEditView(generic.ObjectEditView):
+ queryset = Contact.objects.all()
+ model_form = forms.ContactForm
+
+
+class ContactDeleteView(generic.ObjectDeleteView):
+ queryset = Contact.objects.all()
+
+
+class ContactBulkImportView(generic.BulkImportView):
+ queryset = Contact.objects.all()
+ model_form = forms.ContactCSVForm
+ table = tables.ContactTable
+
+
+class ContactBulkEditView(generic.BulkEditView):
+ queryset = Contact.objects.prefetch_related('group')
+ filterset = filtersets.ContactFilterSet
+ table = tables.ContactTable
+ form = forms.ContactBulkEditForm
+
+
+class ContactBulkDeleteView(generic.BulkDeleteView):
+ queryset = Contact.objects.prefetch_related('group')
+ filterset = filtersets.ContactFilterSet
+ table = tables.ContactTable