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'),
                 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 rest_framework import serializers
 
 
 from netbox.api import WritableNestedSerializer
 from netbox.api import WritableNestedSerializer
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 
 
 __all__ = [
 __all__ = [
+    'NestedContactSerializer',
+    'NestedContactGroupSerializer',
+    'NestedContactRoleSerializer',
     'NestedTenantGroupSerializer',
     'NestedTenantGroupSerializer',
     'NestedTenantSerializer',
     'NestedTenantSerializer',
 ]
 ]
@@ -29,3 +32,33 @@ class NestedTenantSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
         fields = ['id', 'url', 'display', 'name', 'slug']
         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 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 *
 from .nested_serializers import *
 
 
 
 
@@ -43,3 +45,57 @@ class TenantSerializer(PrimaryModelSerializer):
             'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
             'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
             'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_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('tenant-groups', views.TenantGroupViewSet)
 router.register('tenants', views.TenantViewSet)
 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'
 app_name = 'tenancy-api'
 urlpatterns = router.urls
 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 extras.api.views import CustomFieldModelViewSet
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from tenancy import filtersets
 from tenancy import filtersets
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.utils import count_related
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from . import serializers
 from . import serializers
@@ -20,7 +20,7 @@ class TenancyRootView(APIRootView):
 
 
 
 
 #
 #
-# Tenant Groups
+# Tenants
 #
 #
 
 
 class TenantGroupViewSet(CustomFieldModelViewSet):
 class TenantGroupViewSet(CustomFieldModelViewSet):
@@ -35,10 +35,6 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.TenantGroupFilterSet
     filterset_class = filtersets.TenantGroupFilterSet
 
 
 
 
-#
-# Tenants
-#
-
 class TenantViewSet(CustomFieldModelViewSet):
 class TenantViewSet(CustomFieldModelViewSet):
     queryset = Tenant.objects.prefetch_related(
     queryset = Tenant.objects.prefetch_related(
         'group', 'tags'
         'group', 'tags'
@@ -55,3 +51,41 @@ class TenantViewSet(CustomFieldModelViewSet):
     )
     )
     serializer_class = serializers.TenantSerializer
     serializer_class = serializers.TenantSerializer
     filterset_class = filtersets.TenantFilterSet
     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 extras.filters import TagFilter
 from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
 from utilities.filters import TreeNodeMultipleChoiceFilter
 from utilities.filters import TreeNodeMultipleChoiceFilter
-from .models import Tenant, TenantGroup
+from .models import *
 
 
 
 
 __all__ = (
 __all__ = (
+    'ContactAssignmentFilterSet',
+    'ContactFilterSet',
+    'ContactGroupFilterSet',
+    'ContactRoleFilterSet',
     'TenancyFilterSet',
     'TenancyFilterSet',
     'TenantFilterSet',
     'TenantFilterSet',
     'TenantGroupFilterSet',
     'TenantGroupFilterSet',
 )
 )
 
 
 
 
+#
+# Tenancy
+#
+
 class TenantGroupFilterSet(OrganizationalModelFilterSet):
 class TenantGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -23,7 +31,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet):
         field_name='parent__slug',
         field_name='parent__slug',
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        label='Tenant group group (slug)',
+        label='Tenant group (slug)',
     )
     )
 
 
     class Meta:
     class Meta:
@@ -93,3 +101,89 @@ class TenancyFilterSet(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (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 django import forms
 
 
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.forms import BootstrapMixin, DynamicModelChoiceField
 from utilities.forms import BootstrapMixin, DynamicModelChoiceField
 
 
 __all__ = (
 __all__ = (
+    'ContactBulkEditForm',
+    'ContactGroupBulkEditForm',
+    'ContactRoleBulkEditForm',
     'TenantBulkEditForm',
     'TenantBulkEditForm',
     'TenantGroupBulkEditForm',
     'TenantGroupBulkEditForm',
 )
 )
 
 
 
 
+#
+# Tenants
+#
+
 class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
 class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -42,3 +49,53 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
         nullable_fields = [
         nullable_fields = [
             'group',
             '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 extras.forms import CustomFieldModelCSVForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.forms import CSVModelChoiceField, SlugField
 from utilities.forms import CSVModelChoiceField, SlugField
 
 
 __all__ = (
 __all__ = (
+    'ContactCSVForm',
+    'ContactGroupCSVForm',
+    'ContactRoleCSVForm',
     'TenantCSVForm',
     'TenantCSVForm',
     'TenantGroupCSVForm',
     'TenantGroupCSVForm',
 )
 )
 
 
 
 
+#
+# Tenants
+#
+
 class TenantGroupCSVForm(CustomFieldModelCSVForm):
 class TenantGroupCSVForm(CustomFieldModelCSVForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -34,3 +41,43 @@ class TenantCSVForm(CustomFieldModelCSVForm):
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
         fields = ('name', 'slug', 'group', 'description', 'comments')
         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 django.utils.translation import gettext as _
 
 
 from extras.forms import CustomFieldModelFilterForm
 from extras.forms import CustomFieldModelFilterForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField
 
 
+__all__ = (
+    'ContactFilterForm',
+    'ContactGroupFilterForm',
+    'ContactRoleFilterForm',
+    'TenantFilterForm',
+    'TenantGroupFilterForm',
+)
+
+
+#
+# Tenants
+#
 
 
 class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
 class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = TenantGroup
     model = TenantGroup
@@ -40,3 +52,55 @@ class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
         fetch_trigger='open'
         fetch_trigger='open'
     )
     )
     tag = TagFilterField(model)
     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.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.forms import (
 from utilities.forms import (
-    BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
+    BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
 )
 )
 
 
 __all__ = (
 __all__ = (
+    'ContactForm',
+    'ContactGroupForm',
+    'ContactRoleForm',
     'TenantForm',
     'TenantForm',
     'TenantGroupForm',
     'TenantGroupForm',
 )
 )
 
 
 
 
+#
+# Tenants
+#
+
 class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
 class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
@@ -45,3 +52,51 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
         fieldsets = (
         fieldsets = (
             ('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
             ('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 = ObjectField(TenantGroupType)
     tenant_group_list = ObjectListField(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 tenancy import filtersets, models
 from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
 from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
 
 
 __all__ = (
 __all__ = (
+    'ContactAssignmentType',
+    'ContactGroupType',
+    'ContactRoleType',
+    'ContactType',
     'TenantType',
     'TenantType',
     'TenantGroupType',
     '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 TenantType(PrimaryObjectType):
 
 
     class Meta:
     class Meta:
@@ -21,3 +38,39 @@ class TenantGroupType(OrganizationalObjectType):
         model = models.TenantGroup
         model = models.TenantGroup
         fields = '__all__'
         fields = '__all__'
         filterset_class = filtersets.TenantGroupFilterSet
         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.db import models
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 
 
 from extras.utils import extras_features
 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 utilities.querysets import RestrictedQuerySet
+from .choices import *
 
 
 
 
 __all__ = (
 __all__ = (
+    'ContactAssignment',
+    'Contact',
+    'ContactGroup',
+    'ContactRole',
     'Tenant',
     'Tenant',
     'TenantGroup',
     'TenantGroup',
 )
 )
 
 
 
 
+#
+# Tenants
+#
+
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class TenantGroup(NestedGroupModel):
 class TenantGroup(NestedGroupModel):
     """
     """
@@ -90,3 +100,153 @@ class Tenant(PrimaryModel):
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('tenancy:tenant', args=[self.pk])
         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 (
 from utilities.tables import (
     BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
     BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
 )
 )
-from .models import Tenant, TenantGroup
+from .models import *
 
 
 __all__ = (
 __all__ = (
+    'ContactAssignmentTable',
+    'ContactGroupTable',
+    'ContactRoleTable',
+    'ContactTable',
     'TenantColumn',
     'TenantColumn',
     'TenantGroupTable',
     'TenantGroupTable',
     'TenantTable',
     'TenantTable',
@@ -38,7 +42,7 @@ class TenantColumn(tables.TemplateColumn):
 
 
 
 
 #
 #
-# Tenant groups
+# Tenants
 #
 #
 
 
 class TenantGroupTable(BaseTable):
 class TenantGroupTable(BaseTable):
@@ -59,10 +63,6 @@ class TenantGroupTable(BaseTable):
         default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
 
 
 
 
-#
-# Tenants
-#
-
 class TenantTable(BaseTable):
 class TenantTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.Column(
     name = tables.Column(
@@ -80,3 +80,69 @@ class TenantTable(BaseTable):
         model = Tenant
         model = Tenant
         fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
         fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
         default_columns = ('pk', 'name', 'group', 'description')
         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 django.urls import reverse
 
 
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
 
 
@@ -92,3 +92,112 @@ class TenantTest(APIViewTestCases.APIViewTestCase):
                 'group': tenant_groups[1].pk,
                 '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 django.test import TestCase
 
 
 from tenancy.filtersets import *
 from tenancy.filtersets import *
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.testing import ChangeLoggedFilterSetTests
 from utilities.testing import ChangeLoggedFilterSetTests
 
 
 
 
@@ -84,3 +84,103 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'group': [group[0].slug, group[1].slug]}
         params = {'group': [group[0].slug, group[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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
 from utilities.testing import ViewTestCases, create_tags
 
 
 
 
@@ -74,3 +74,105 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'group': tenant_groups[1].pk,
             '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 extras.views import ObjectChangeLogView, ObjectJournalView
 from utilities.views import SlugRedirectView
 from utilities.views import SlugRedirectView
 from . import views
 from . import views
-from .models import Tenant, TenantGroup
+from .models import *
 
 
 app_name = 'tenancy'
 app_name = 'tenancy'
 urlpatterns = [
 urlpatterns = [
@@ -32,4 +32,39 @@ urlpatterns = [
     path('tenants/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
     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}),
     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 ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from netbox.views import generic
 from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
+from utilities.utils import count_related
 from virtualization.models import VirtualMachine, Cluster
 from virtualization.models import VirtualMachine, Cluster
 from . import filtersets, forms, tables
 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')
     queryset = Tenant.objects.prefetch_related('group')
     filterset = filtersets.TenantFilterSet
     filterset = filtersets.TenantFilterSet
     table = tables.TenantTable
     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