Просмотр исходного кода

Merge pull request #7575 from netbox-community/1344-contacts

Closes #1344: Contact objects
Jeremy Stretch 4 лет назад
Родитель
Сommit
ba7361bdc7
50 измененных файлов с 1971 добавлено и 75 удалено
  1. 5 0
      docs/core-functionality/contacts.md
  2. 31 0
      docs/models/tenancy/contact.md
  3. 3 0
      docs/models/tenancy/contactgroup.md
  4. 3 0
      docs/models/tenancy/contactrole.md
  5. 1 0
      mkdocs.yml
  6. 10 0
      netbox/circuits/models.py
  7. 10 0
      netbox/dcim/models/devices.py
  8. 5 0
      netbox/dcim/models/power.py
  9. 5 0
      netbox/dcim/models/racks.py
  10. 20 0
      netbox/dcim/models/sites.py
  11. 8 0
      netbox/netbox/navigation_menu.py
  12. 6 5
      netbox/templates/circuits/circuit.html
  13. 2 1
      netbox/templates/circuits/provider.html
  14. 1 0
      netbox/templates/dcim/device.html
  15. 1 0
      netbox/templates/dcim/location.html
  16. 1 0
      netbox/templates/dcim/manufacturer.html
  17. 1 0
      netbox/templates/dcim/powerpanel.html
  18. 1 0
      netbox/templates/dcim/rack.html
  19. 1 0
      netbox/templates/dcim/region.html
  20. 52 37
      netbox/templates/dcim/site.html
  21. 1 0
      netbox/templates/dcim/sitegroup.html
  22. 49 0
      netbox/templates/inc/contacts_panel.html
  23. 79 0
      netbox/templates/tenancy/contact.html
  24. 76 0
      netbox/templates/tenancy/contactgroup.html
  25. 52 0
      netbox/templates/tenancy/contactrole.html
  26. 1 0
      netbox/templates/tenancy/tenant.html
  27. 1 0
      netbox/templates/virtualization/cluster.html
  28. 1 0
      netbox/templates/virtualization/clustergroup.html
  29. 1 0
      netbox/templates/virtualization/virtualmachine.html
  30. 34 1
      netbox/tenancy/api/nested_serializers.py
  31. 61 2
      netbox/tenancy/api/serializers.py
  32. 6 0
      netbox/tenancy/api/urls.py
  33. 40 6
      netbox/tenancy/api/views.py
  34. 19 0
      netbox/tenancy/choices.py
  35. 99 4
      netbox/tenancy/filtersets.py
  36. 73 1
      netbox/tenancy/forms/bulk_edit.py
  37. 48 1
      netbox/tenancy/forms/bulk_import.py
  38. 65 1
      netbox/tenancy/forms/filtersets.py
  39. 89 2
      netbox/tenancy/forms/models.py
  40. 12 0
      netbox/tenancy/graphql/schema.py
  41. 53 0
      netbox/tenancy/graphql/types.py
  42. 91 0
      netbox/tenancy/migrations/0003_contacts.py
  43. 177 2
      netbox/tenancy/models.py
  44. 86 7
      netbox/tenancy/tables.py
  45. 110 1
      netbox/tenancy/tests/test_api.py
  46. 101 1
      netbox/tenancy/tests/test_filtersets.py
  47. 103 1
      netbox/tenancy/tests/test_views.py
  48. 41 1
      netbox/tenancy/urls.py
  49. 220 1
      netbox/tenancy/views.py
  50. 15 0
      netbox/virtualization/models.py

+ 5 - 0
docs/core-functionality/contacts.md

@@ -0,0 +1,5 @@
+# Contacts
+
+{!models/tenancy/contact.md!}
+{!models/tenancy/contactgroup.md!}
+{!models/tenancy/contactrole.md!}

+ 31 - 0
docs/models/tenancy/contact.md

@@ -0,0 +1,31 @@
+# Contacts
+
+A contact represent an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. Contacts can be arranged within nested contact groups.
+
+Each contact must include a name, which is unique to its parent group (if any). The following optional descriptors are also available:
+
+* Title
+* Phone
+* Email
+* Address
+
+## Contact Assignment
+
+Each contact can be assigned to one or more objects, allowing for the efficient reuse of contact information. When assigning a contact to an object, the user may optionally specify a role and/or priority (primary, secondary, tertiary, or inactive) to better convey the nature of the contact's relationship to the assigned object.
+
+The following models support the assignment of contacts:
+
+* circuits.Circuit
+* circuits.Provider
+* dcim.Device
+* dcim.Location
+* dcim.Manufacturer
+* dcim.PowerPanel
+* dcim.Rack
+* dcim.Region
+* dcim.Site
+* dcim.SiteGroup
+* tenancy.Tenant
+* virtualization.Cluster
+* virtualization.ClusterGroup
+* virtualization.VirtualMachine

+ 3 - 0
docs/models/tenancy/contactgroup.md

@@ -0,0 +1,3 @@
+# Contact Groups
+
+Contacts can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated.

+ 3 - 0
docs/models/tenancy/contactrole.md

@@ -0,0 +1,3 @@
+# Contact Roles
+
+Contacts can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts.

+ 1 - 0
mkdocs.yml

@@ -62,6 +62,7 @@ nav:
         - Circuits: 'core-functionality/circuits.md'
         - Power Tracking: 'core-functionality/power.md'
         - Tenancy: 'core-functionality/tenancy.md'
+        - Contacts: 'core-functionality/contacts.md'
     - Customization:
         - Custom Fields: 'customization/custom-fields.md'
         - Custom Validation: 'customization/custom-validation.md'

+ 10 - 0
netbox/circuits/models.py

@@ -62,6 +62,11 @@ class Provider(PrimaryModel):
         blank=True
     )
 
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = RestrictedQuerySet.as_manager()
 
     clone_fields = [
@@ -203,6 +208,11 @@ class Circuit(PrimaryModel):
     comments = models.TextField(
         blank=True
     )
+
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
         to='extras.ImageAttachment'
     )

+ 10 - 0
netbox/dcim/models/devices.py

@@ -54,6 +54,11 @@ class Manufacturer(OrganizationalModel):
         blank=True
     )
 
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = RestrictedQuerySet.as_manager()
 
     class Meta:
@@ -584,6 +589,11 @@ class Device(PrimaryModel, ConfigContextModel):
     comments = models.TextField(
         blank=True
     )
+
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
         to='extras.ImageAttachment'
     )

+ 5 - 0
netbox/dcim/models/power.py

@@ -40,6 +40,11 @@ class PowerPanel(PrimaryModel):
     name = models.CharField(
         max_length=100
     )
+
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
         to='extras.ImageAttachment'
     )

+ 5 - 0
netbox/dcim/models/racks.py

@@ -175,12 +175,17 @@ class Rack(PrimaryModel):
     comments = models.TextField(
         blank=True
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         object_id_field='scope_id',
         related_query_name='rack'
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
         to='extras.ImageAttachment'
     )

+ 20 - 0
netbox/dcim/models/sites.py

@@ -52,12 +52,17 @@ class Region(NestedGroupModel):
         max_length=200,
         blank=True
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         object_id_field='scope_id',
         related_query_name='region'
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
     def get_absolute_url(self):
         return reverse('dcim:region', args=[self.pk])
@@ -100,12 +105,17 @@ class SiteGroup(NestedGroupModel):
         max_length=200,
         blank=True
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         object_id_field='scope_id',
         related_query_name='site_group'
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
     def get_absolute_url(self):
         return reverse('dcim:sitegroup', args=[self.pk])
@@ -221,12 +231,17 @@ class Site(PrimaryModel):
     comments = models.TextField(
         blank=True
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         object_id_field='scope_id',
         related_query_name='site'
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
         to='extras.ImageAttachment'
     )
@@ -291,12 +306,17 @@ class Location(NestedGroupModel):
         max_length=200,
         blank=True
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         object_id_field='scope_id',
         related_query_name='location'
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
         to='extras.ImageAttachment'
     )

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

+ 6 - 5
netbox/templates/circuits/circuit.html

@@ -70,11 +70,12 @@
         {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">
-        {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
-        {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
-        {% include 'inc/image_attachments_panel.html' %}
-        {% plugin_right_page object %}
-    </div>
+    {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
+    {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
+    {% include 'inc/contacts_panel.html' %}
+    {% include 'inc/image_attachments_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
 </div>
 <div class="row">
     <div class="col col-md-12">

+ 2 - 1
netbox/templates/circuits/provider.html

@@ -47,12 +47,13 @@
                 </table>
             </div>
         </div>
+        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
         {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
         {% include 'inc/custom_fields_panel.html' %}
-        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
         {% include 'inc/comments_panel.html' %}
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_right_page object %}
     </div>
     <div class="col col-md-12">

+ 1 - 0
netbox/templates/dcim/device.html

@@ -296,6 +296,7 @@
                 </div>
                 {% endif %}
             </div>
+            {% include 'inc/contacts_panel.html' %}
             {% include 'inc/image_attachments_panel.html' %}
             <div class="card noprint">
                 <h5 class="card-header">

+ 1 - 0
netbox/templates/dcim/location.html

@@ -72,6 +72,7 @@
   </div>
 	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% include 'inc/image_attachments_panel.html' %}
     {% plugin_right_page object %}
 	</div>

+ 1 - 0
netbox/templates/dcim/manufacturer.html

@@ -38,6 +38,7 @@
 	</div>
 	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_right_page object %}
   </div>
 </div>

+ 1 - 0
netbox/templates/dcim/powerpanel.html

@@ -44,6 +44,7 @@
     </div>
 	<div class="col col-md-6">
         {% include 'inc/custom_fields_panel.html' %}
+        {% include 'inc/contacts_panel.html' %}
         {% include 'inc/image_attachments_panel.html' %}
         {% plugin_right_page object %}
     </div>

+ 1 - 0
netbox/templates/dcim/rack.html

@@ -332,6 +332,7 @@
                 </div>
             {% endif %}
         </div>
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_right_page object %}
     </div>
   </div>

+ 1 - 0
netbox/templates/dcim/region.html

@@ -46,6 +46,7 @@
       </div>
     </div>
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_left_page object %}
   </div>
 	<div class="col col-md-6">

+ 52 - 37
netbox/templates/dcim/site.html

@@ -76,6 +76,10 @@
                         <th scope="row">Facility</th>
                         <td>{{ object.facility|placeholder }}</td>
                     </tr>
+                    <tr>
+                        <th scope="row">Description</th>
+                        <td>{{ object.description|placeholder }}</td>
+                    </tr>
                     <tr>
                         <th scope="row">AS Number</th>
                         <td>{{ object.asn|placeholder }}</td>
@@ -91,19 +95,6 @@
                             {% endif %}
                         </td>
                     </tr>
-                    <tr>
-                        <th scope="row">Description</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
-        </div>
-        <div class="card">
-            <h5 class="card-header">
-                Contact Info
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
                     <tr>
                         <th scope="row">Physical Address</th>
                         <td>
@@ -138,33 +129,57 @@
                             {% endif %}
                         </td>
                     </tr>
-                    <tr>
-                        <th scope="row">Contact Name</th>
-                        <td>{{ object.contact_name|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Contact Phone</th>
-                        <td>
-                            {% if object.contact_phone %}
-                                <a href="tel:{{ object.contact_phone }}">{{ object.contact_phone }}</a>
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Contact E-Mail</th>
-                        <td>
-                            {% if object.contact_email %}
-                                <a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                    </tr>
                 </table>
             </div>
         </div>
+        {% include 'inc/contacts_panel.html' %}
+        <div class="card">
+            <h5 class="card-header">Contact Info</h5>
+            <div class="card-body">
+              {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %}
+                <table class="table table-hover attr-table">
+                  <tr>
+                    <th scope="row">Contact Name</th>
+                    <td>
+                      {% if object.contact_name %}
+                        <div class="float-end text-warning">
+                          <i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
+                        </div>
+                      {% endif %}
+                      {{ object.contact_name|placeholder }}
+                    </td>
+                  </tr>
+                  <tr>
+                    <th scope="row">Contact Phone</th>
+                    <td>
+                      {% if object.contact_phone %}
+                        <div class="float-end text-warning">
+                          <i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
+                        </div>
+                        <a href="tel:{{ object.contact_phone }}">{{ object.contact_phone }}</a>
+                      {% else %}
+                        <span class="text-muted">&mdash;</span>
+                      {% endif %}
+                    </td>
+                  </tr>
+                  <tr>
+                    <th scope="row">Contact E-Mail</th>
+                    <td>
+                      {% if object.contact_email %}
+                        <div class="float-end text-warning">
+                          <i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
+                        </div>
+                        <a href="tel:{{ object.contact_
+                        <a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
+                      {% else %}
+                        <span class="text-muted">&mdash;</span>
+                      {% endif %}
+                    </td>
+                  </tr>
+                </table>
+              {% endwith %}
+            </div>
+        </div>
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
         {% include 'inc/comments_panel.html' %}

+ 1 - 0
netbox/templates/dcim/sitegroup.html

@@ -46,6 +46,7 @@
       </div>
     </div>
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_left_page object %}
   </div>
 	<div class="col col-md-6">

+ 49 - 0
netbox/templates/inc/contacts_panel.html

@@ -0,0 +1,49 @@
+{% load helpers %}
+
+<div class="card">
+  <h5 class="card-header">Contacts</h5>
+  <div class="card-body">
+    {% with contacts=object.contacts.all %}
+      {% if contacts.exists %}
+        <table class="table table-hover">
+          <tr>
+            <th>Name</th>
+            <th>Role</th>
+            <th>Priority</th>
+            <th></th>
+          </tr>
+          {% for contact in contacts %}
+            <tr>
+              <td>
+                <a href="{{ contact.contact.get_absolute_url }}">{{ contact.contact }}</a>
+              </td>
+              <td>{{ contact.role|placeholder }}</td>
+              <td>{{ contact.get_priority_display|placeholder }}</td>
+              <td class="text-end noprint">
+                {% if perms.tenancy.change_contactassignment %}
+                  <a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit">
+                    <i class="mdi mdi-pencil" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+                {% if perms.tenancy.delete_contactassignment %}
+                  <a href="{% url 'extras:imageattachment_delete' pk=contact.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete">
+                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+              </td>
+            </tr>
+          {% endfor %}
+        </table>
+      {% else %}
+        <div class="text-muted">None</div>
+      {% endif %}
+    {% endwith %}
+  </div>
+  {% if perms.tenancy.add_contactassignment %}
+    <div class="card-footer text-end noprint">
+      <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
+      </a>
+    </div>
+  {% endif %}
+</div>

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

@@ -0,0 +1,79 @@
+{% 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>
+            <tr>
+              <th scope="row">Assignments</th>
+              <td>
+                <a href="{% url 'tenancy:contact_list' %}?contact_id={{ object.pk }}">{{ assignment_count }}</a>
+              </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">
+      <div class="card">
+        <h5 class="card-header">Assignments</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 %}

+ 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 %}

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

@@ -0,0 +1,52 @@
+{% 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>
+            <tr>
+              <th scope="row">Assignments</th>
+              <td>
+                <a href="{% url 'tenancy:contact_list' %}?role={{ object.slug }}">{{ assignment_count }}</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">
+        <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 %}

+ 1 - 0
netbox/templates/tenancy/tenant.html

@@ -38,6 +38,7 @@
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
         {% include 'inc/comments_panel.html' %}
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-5">

+ 1 - 0
netbox/templates/virtualization/cluster.html

@@ -62,6 +62,7 @@
   <div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_right_page object %}
   </div>
 </div>

+ 1 - 0
netbox/templates/virtualization/clustergroup.html

@@ -32,6 +32,7 @@
 	</div>
 	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_right_page object %}
   </div>
 </div>

+ 1 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -173,6 +173,7 @@
                 </div>
             {% endif %}
         </div>
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_right_page object %}
     </div>
 </div>

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

+ 61 - 2
netbox/tenancy/api/serializers.py

@@ -1,7 +1,10 @@
+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 ChoiceField, ContentTypeField
+from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer
+from tenancy.choices import ContactPriorityChoices
+from tenancy.models import *
 from .nested_serializers import *
 
 
@@ -43,3 +46,59 @@ 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)
+    priority = ChoiceField(choices=ContactPriorityChoices, required=False)
+
+    class Meta:
+        model = ContactAssignment
+        fields = [
+            'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'priority', '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'),
+    )

+ 99 - 4
netbox/tenancy/filtersets.py

@@ -2,18 +2,26 @@ import django_filters
 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 netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
+from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
+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,90 @@ 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(ChangeLoggedModelFilterSet):
+    content_type = ContentTypeFilter()
+    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', 'content_type_id', 'priority']

+ 73 - 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,68 @@ 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
+    )
+    title = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    phone = forms.CharField(
+        max_length=50,
+        required=False
+    )
+    email = forms.EmailField(
+        required=False
+    )
+    address = forms.CharField(
+        max_length=200,
+        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)

+ 89 - 2
netbox/tenancy/forms/models.py

@@ -1,16 +1,27 @@
+from django import forms
+
 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,
+    StaticSelect,
 )
 
 __all__ = (
+    'ContactAssignmentForm',
+    'ContactForm',
+    'ContactGroupForm',
+    'ContactRoleForm',
     'TenantForm',
     'TenantGroupForm',
 )
 
 
+#
+# Tenants
+#
+
 class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
     parent = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
@@ -45,3 +56,79 @@ 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}),
+        }
+
+
+class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
+    group = DynamicModelChoiceField(
+        queryset=ContactGroup.objects.all(),
+        required=False,
+        initial_params={
+            'contacts': '$contact'
+        }
+    )
+    contact = DynamicModelChoiceField(
+        queryset=Contact.objects.all(),
+        query_params={
+            'group_id': '$group'
+        }
+    )
+    role = DynamicModelChoiceField(
+        queryset=ContactRole.objects.all()
+    )
+
+    class Meta:
+        model = ContactAssignment
+        fields = (
+            'group', 'contact', 'role', 'priority',
+        )
+        widgets = {
+            'priority': StaticSelect(),
+        }

+ 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

+ 91 - 0
netbox/tenancy/migrations/0003_contacts.py

@@ -0,0 +1,91 @@
+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='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)),
+                ('slug', models.SlugField(max_length=100)),
+                ('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'],
+                'unique_together': {('parent', 'name')},
+            },
+        ),
+        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)),
+                ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ['name'],
+                'unique_together': {('group', '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'),
+                'unique_together': {('content_type', 'object_id', 'contact', 'role', 'priority')},
+            },
+        ),
+    ]

+ 177 - 2
netbox/tenancy/models.py

@@ -1,19 +1,29 @@
-from django.core.exceptions import ValidationError
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+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):
     """
@@ -76,6 +86,11 @@ class Tenant(PrimaryModel):
         blank=True
     )
 
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = RestrictedQuerySet.as_manager()
 
     clone_fields = [
@@ -90,3 +105,163 @@ 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
+    )
+    slug = models.SlugField(
+        max_length=100
+    )
+    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']
+        unique_together = (
+            ('parent', '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']
+        unique_together = (
+            ('group', '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')
+        unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority')
+
+    def __str__(self):
+        if self.priority:
+            return f"{self.contact} ({self.get_priority_display()})"
+        return str(self.contact)

+ 86 - 7
netbox/tenancy/tables.py

@@ -1,11 +1,15 @@
 import django_tables2 as tables
 
 from utilities.tables import (
-    BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
+    BaseTable, ButtonsColumn, ContentTypeColumn, 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,82 @@ 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()
+    assignment_count = tables.Column(
+        verbose_name='Assignments'
+    )
+    tags = TagColumn(
+        url_name='tenancy:tenant_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Contact
+        fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags')
+        default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email')
+
+
+class ContactAssignmentTable(BaseTable):
+    pk = ToggleColumn()
+    content_type = ContentTypeColumn(
+        verbose_name='Object Type'
+    )
+    object = tables.Column(
+        linkify=True,
+        orderable=False
+    )
+    contact = tables.Column(
+        linkify=True
+    )
+    role = tables.Column(
+        linkify=True
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ContactAssignment
+        fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
+        default_columns = ('pk', 'object', '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,
+        }

+ 41 - 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,44 @@ 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}),
+
+    # Contact assignments
+    path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),
+    path('contact-assignments/<int:pk>/edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'),
+    path('contact-assignments/<int:pk>/delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'),
+
 ]

+ 220 - 1
netbox/tenancy/views.py

@@ -1,11 +1,16 @@
+from django.contrib.contenttypes.models import ContentType
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+
 from circuits.models import Circuit
 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 +145,217 @@ 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)
+        contacts_table.columns.hide('role')
+        paginate_table(contacts_table, request)
+
+        return {
+            'contacts_table': contacts_table,
+            'assignment_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.annotate(
+        assignment_count=count_related(ContactAssignment, 'contact')
+    )
+    filterset = filtersets.ContactFilterSet
+    filterset_form = forms.ContactFilterForm
+    table = tables.ContactTable
+
+
+class ContactView(generic.ObjectView):
+    queryset = Contact.objects.all()
+
+    def get_extra_context(self, request, instance):
+        contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
+            contact=instance
+        )
+        contacts_table = tables.ContactAssignmentTable(contact_assignments)
+        contacts_table.columns.hide('contact')
+        paginate_table(contacts_table, request)
+
+        return {
+            'contacts_table': contacts_table,
+            'assignment_count': ContactAssignment.objects.filter(contact=instance).count(),
+        }
+
+
+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
+
+
+#
+# Contact assignments
+#
+
+class ContactAssignmentEditView(generic.ObjectEditView):
+    queryset = ContactAssignment.objects.all()
+    model_form = forms.ContactAssignmentForm
+
+    def alter_obj(self, instance, request, args, kwargs):
+        if not instance.pk:
+            # Assign the object based on URL kwargs
+            try:
+                app_label, model = request.GET.get('content_type').split('.')
+            except (AttributeError, ValueError):
+                raise Http404("Content type not specified")
+            content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
+            instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
+        return instance
+
+    def get_return_url(self, request, obj=None):
+        return obj.object.get_absolute_url() if obj else super().get_return_url(request)
+
+
+class ContactAssignmentDeleteView(generic.ObjectDeleteView):
+    queryset = ContactAssignment.objects.all()
+
+    def get_return_url(self, request, obj=None):
+        return obj.object.get_absolute_url() if obj else super().get_return_url(request)

+ 15 - 0
netbox/virtualization/models.py

@@ -81,12 +81,17 @@ class ClusterGroup(OrganizationalModel):
         max_length=200,
         blank=True
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         object_id_field='scope_id',
         related_query_name='cluster_group'
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
     objects = RestrictedQuerySet.as_manager()
 
@@ -142,12 +147,17 @@ class Cluster(PrimaryModel):
     comments = models.TextField(
         blank=True
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         object_id_field='scope_id',
         related_query_name='cluster'
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
     objects = RestrictedQuerySet.as_manager()
 
@@ -268,6 +278,11 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
         blank=True
     )
 
+    # Generic relation
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = ConfigContextModelQuerySet.as_manager()
 
     clone_fields = [