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