jeremystretch 4 лет назад
Родитель
Сommit
2e78568d4d

+ 8 - 0
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'),
+            ),
+        ),
     ),
 )
 

+ 66 - 0
netbox/templates/tenancy/contact.html

@@ -0,0 +1,66 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  {% if object.group %}
+    <li class="breadcrumb-item"><a href="{% url 'tenancy:contact_list' %}?group_id={{ object.group.pk }}">{{ object.group }}</a></li>
+  {% endif %}
+{% endblock breadcrumbs %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-7">
+      <div class="card">
+        <h5 class="card-header">Tenant</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <td>Group</td>
+              <td>
+                {% if object.group %}
+                  <a href="{{ object.group.get_absolute_url }}">{{ object.group }}</a>
+                {% else %}
+                  <span class="text-muted">None</span>
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <td>Name</td>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <td>Title</td>
+              <td>{{ object.tile|placeholder }}</td>
+            </tr>
+            <tr>
+              <td>Phone</td>
+              <td>{{ object.phone|placeholder }}</td>
+            </tr>
+            <tr>
+              <td>Email</td>
+              <td>{{ object.email|placeholder }}</td>
+            </tr>
+            <tr>
+              <td>Address</td>
+              <td>{{ object.address|linebreaksbr|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/comments_panel.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-5">
+      {% 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 %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 76 - 0
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 %}
+    <li class="breadcrumb-item"><a href="{% url 'tenancy:contactgroup_list' %}?parent_id={{ contactgroup.pk }}">{{ contactgroup }}</a></li>
+  {% endfor %}
+{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">
+          Contact Group
+        </h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Name</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Parent</th>
+              <td>
+                {% if object.parent %}
+                  <a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
+                {% else %}
+                  <span class="text-muted">&mdash;</span>
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Contacts</th>
+              <td>
+                <a href="{% url 'tenancy:contact_list' %}?group_id={{ object.pk }}">{{ contacts_table.rows|length }}</a>
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/custom_fields_panel.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <div class="card-header">
+          Tenants
+        </div>
+        <div class="card-body">
+          {% include 'inc/table.html' with table=contacts_table %}
+        </div>
+        {% if perms.tenancy.add_contact %}
+          <div class="card-footer text-end noprint">
+            <a href="{% url 'tenancy:contact_add' %}?group={{ object.pk }}" class="btn btn-sm btn-primary">
+              <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Contact
+            </a>
+          </div>
+        {% endif %}
+        </div>
+        {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
+        {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 46 - 0
netbox/templates/tenancy/contactrole.html

@@ -0,0 +1,46 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'tenancy:contactrole_list' %}">Contact Roles</a></li>
+{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Contact Role</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Name</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/custom_fields_panel.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">Assigned Contacts</h5>
+        <div class="card-body">
+          {% include 'inc/table.html' with table=contacts_table %}
+        </div>
+      </div>
+      {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 34 - 1
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']

+ 58 - 2
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',
+        ]

+ 6 - 0
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

+ 40 - 6
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

+ 19 - 0
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'),
+    )

+ 96 - 2
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']

+ 58 - 1
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']

+ 48 - 1
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')

+ 65 - 1
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)

+ 57 - 2
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}),
+        }

+ 12 - 0
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)

+ 53 - 0
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

+ 98 - 0
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'),
+        ),
+    ]

+ 162 - 2
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')

+ 72 - 6
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')

+ 110 - 1
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,
+            },
+        ]

+ 101 - 1
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)

+ 103 - 1
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,
+        }

+ 36 - 1
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/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
     path('tenants/<int:pk>/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/<int:pk>/', views.ContactGroupView.as_view(), name='contactgroup'),
+    path('contact-groups/<int:pk>/edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'),
+    path('contact-groups/<int:pk>/delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'),
+    path('contact-groups/<int:pk>/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/<int:pk>/', views.ContactRoleView.as_view(), name='contactrole'),
+    path('contact-roles/<int:pk>/edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'),
+    path('contact-roles/<int:pk>/delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'),
+    path('contact-roles/<int:pk>/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/<int:pk>/', views.ContactView.as_view(), name='contact'),
+    path('contacts/<slug:slug>/', SlugRedirectView.as_view(), kwargs={'model': Contact}),
+    path('contacts/<int:pk>/edit/', views.ContactEditView.as_view(), name='contact_edit'),
+    path('contacts/<int:pk>/delete/', views.ContactDeleteView.as_view(), name='contact_delete'),
+    path('contacts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
+    path('contacts/<int:pk>/journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}),
+
 ]

+ 170 - 1
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