diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md new file mode 100644 index 00000000000..76a005fc062 --- /dev/null +++ b/docs/core-functionality/contacts.md @@ -0,0 +1,5 @@ +# Contacts + +{!models/tenancy/contact.md!} +{!models/tenancy/contactgroup.md!} +{!models/tenancy/contactrole.md!} diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md new file mode 100644 index 00000000000..9d81e2d858c --- /dev/null +++ b/docs/models/tenancy/contact.md @@ -0,0 +1,31 @@ +# Contacts + +A contact represent an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. Contacts can be arranged within nested contact groups. + +Each contact must include a name, which is unique to its parent group (if any). The following optional descriptors are also available: + +* Title +* Phone +* Email +* Address + +## Contact Assignment + +Each contact can be assigned to one or more objects, allowing for the efficient reuse of contact information. When assigning a contact to an object, the user may optionally specify a role and/or priority (primary, secondary, tertiary, or inactive) to better convey the nature of the contact's relationship to the assigned object. + +The following models support the assignment of contacts: + +* circuits.Circuit +* circuits.Provider +* dcim.Device +* dcim.Location +* dcim.Manufacturer +* dcim.PowerPanel +* dcim.Rack +* dcim.Region +* dcim.Site +* dcim.SiteGroup +* tenancy.Tenant +* virtualization.Cluster +* virtualization.ClusterGroup +* virtualization.VirtualMachine diff --git a/docs/models/tenancy/contactgroup.md b/docs/models/tenancy/contactgroup.md new file mode 100644 index 00000000000..ea566c58a4c --- /dev/null +++ b/docs/models/tenancy/contactgroup.md @@ -0,0 +1,3 @@ +# Contact Groups + +Contacts can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated. diff --git a/docs/models/tenancy/contactrole.md b/docs/models/tenancy/contactrole.md new file mode 100644 index 00000000000..23642ca03ed --- /dev/null +++ b/docs/models/tenancy/contactrole.md @@ -0,0 +1,3 @@ +# Contact Roles + +Contacts can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts. diff --git a/mkdocs.yml b/mkdocs.yml index 7244c36d6f2..72750d6f517 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - Circuits: 'core-functionality/circuits.md' - Power Tracking: 'core-functionality/power.md' - Tenancy: 'core-functionality/tenancy.md' + - Contacts: 'core-functionality/contacts.md' - Customization: - Custom Fields: 'customization/custom-fields.md' - Custom Validation: 'customization/custom-validation.md' diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index bc7dcc2197c..3d213b48d2c 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -62,6 +62,11 @@ class Provider(PrimaryModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() clone_fields = [ @@ -203,6 +208,11 @@ class Circuit(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 669f5cfbd39..308a094c3b9 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -54,6 +54,11 @@ class Manufacturer(OrganizationalModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() class Meta: @@ -584,6 +589,11 @@ class Device(PrimaryModel, ConfigContextModel): comments = models.TextField( blank=True ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 0e9520b36dc..f4d0ce8df60 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -40,6 +40,11 @@ class PowerPanel(PrimaryModel): name = models.CharField( max_length=100 ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c287d7d6c73..47fcd42e446 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -175,12 +175,17 @@ class Rack(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='rack' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index b343f61f297..0d9816b0b2c 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -52,12 +52,17 @@ class Region(NestedGroupModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='region' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -100,12 +105,17 @@ class SiteGroup(NestedGroupModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='site_group' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -221,12 +231,17 @@ class Site(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='site' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) @@ -291,12 +306,17 @@ class Location(NestedGroupModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='location' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a3978f16e35..de2c170a373 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -120,6 +120,14 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')): 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/circuits/circuit.html b/netbox/templates/circuits/circuit.html index b863a8a0ef2..3a8096351d5 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -70,11 +70,12 @@
{% plugin_left_page object %}
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} - {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/image_attachments_panel.html' %} - {% plugin_right_page object %} -
+ {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} + {% include 'inc/contacts_panel.html' %} + {% include 'inc/image_attachments_panel.html' %} + {% plugin_right_page object %} +
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 4d35da0e66d..af883e56faa 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -47,12 +47,13 @@
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %} {% plugin_left_page object %}
{% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %} {% include 'inc/comments_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ec1ea3fa1e8..9ae9df7d485 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -296,6 +296,7 @@
{% endif %} + {% include 'inc/contacts_panel.html' %} {% include 'inc/image_attachments_panel.html' %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index cd0f2a92a11..459880ca80c 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -72,6 +72,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 85d76f14fa8..2a56b57cc11 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -38,6 +38,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b1367aa1efa..10975fa1b10 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -44,6 +44,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 5d44e21253e..0196a9a183e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -332,6 +332,7 @@
{% endif %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index b46c905c318..1ee21a60e8c 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -46,6 +46,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1ee8cfce0b6..92023f8d6c6 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -76,6 +76,10 @@
Facility {{ object.facility|placeholder }} + + Description + {{ object.description|placeholder }} + AS Number {{ object.asn|placeholder }} @@ -91,19 +95,6 @@
{% endif %} - - Description - {{ object.description|placeholder }} - - -
- -
-
- Contact Info -
-
- - - - - - - - - - - - -
Physical Address @@ -138,33 +129,57 @@
{% endif %}
Contact Name{{ object.contact_name|placeholder }}
Contact Phone - {% if object.contact_phone %} - {{ object.contact_phone }} - {% else %} - - {% endif %} -
Contact E-Mail - {% if object.contact_email %} - {{ object.contact_email }} - {% else %} - - {% endif %} -
+ {% include 'inc/contacts_panel.html' %} +
+
Contact Info
+
+ {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %} + + + + + + + + + + + + + +
Contact Name + {% if object.contact_name %} +
+ +
+ {% endif %} + {{ object.contact_name|placeholder }} +
Contact Phone + {% if object.contact_phone %} +
+ +
+ {{ object.contact_phone }} + {% else %} + + {% endif %} +
Contact E-Mail + {% if object.contact_email %} +
+ +
+ {{ object.contact_email }} + {% else %} + + {% endif %} +
+ {% endwith %} +
+
{% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} {% include 'inc/comments_panel.html' %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 856a86d64c5..61091707830 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -46,6 +46,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/inc/contacts_panel.html b/netbox/templates/inc/contacts_panel.html new file mode 100644 index 00000000000..33788a5614b --- /dev/null +++ b/netbox/templates/inc/contacts_panel.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
+
Contacts
+
+ {% with contacts=object.contacts.all %} + {% if contacts.exists %} + + + + + + + + {% for contact in contacts %} + + + + + + + {% endfor %} +
NameRolePriority
+ {{ contact.contact }} + {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} + {% if perms.tenancy.change_contactassignment %} + + + + {% endif %} + {% if perms.tenancy.delete_contactassignment %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.tenancy.add_contactassignment %} + + {% endif %} +
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html new file mode 100644 index 00000000000..ca46fdb311f --- /dev/null +++ b/netbox/templates/tenancy/contact.html @@ -0,0 +1,79 @@ +{% 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 }}
Assignments + {{ assignment_count }} +
+
+
+ {% 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 %} +
+
+
+
+
+
Assignments
+
+ {% 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/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html new file mode 100644 index 00000000000..1511565c3f9 --- /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 00000000000..f081afc34b0 --- /dev/null +++ b/netbox/templates/tenancy/contactrole.html @@ -0,0 +1,52 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+
+
Contact Role
+
+ + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Assignments + {{ assignment_count }} +
+
+
+ {% 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/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index dee7f7ce736..54b29e94622 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -38,6 +38,7 @@
{% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %} {% include 'inc/comments_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 769ae431f11..fa8cad03933 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -62,6 +62,7 @@
{% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index f7e8cbe5b7f..fd83c10f3c0 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -32,6 +32,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 249ef91e459..0ef5901122b 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -173,6 +173,7 @@
{% endif %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 11225fa7ad6..a072331f560 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 3136c811cf3..27a14b35020 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,7 +1,10 @@ +from django.contrib.auth.models import ContentType from rest_framework import serializers -from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer -from tenancy.models import Tenant, TenantGroup +from netbox.api import ChoiceField, ContentTypeField +from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer +from tenancy.choices import ContactPriorityChoices +from tenancy.models import * from .nested_serializers import * @@ -43,3 +46,59 @@ class Meta: 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] + + +# +# Contacts +# + +class ContactGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') + parent = NestedContactGroupSerializer(required=False, allow_null=True) + contact_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ContactGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'contact_count', '_depth', + ] + + +class ContactRoleSerializer(OrganizationalModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') + + class Meta: + model = ContactRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + ] + + +class ContactSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail') + group = NestedContactGroupSerializer(required=False, allow_null=True) + + class Meta: + model = Contact + fields = [ + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + +class ContactAssignmentSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + content_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + contact = NestedContactSerializer() + role = NestedContactRoleSerializer(required=False, allow_null=True) + priority = ChoiceField(choices=ContactPriorityChoices, required=False) + + class Meta: + model = ContactAssignment + fields = [ + 'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'priority', 'created', + 'last_updated', + ] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 32540879d7b..00e1a646943 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -9,5 +9,11 @@ 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 2e049135dbd..7ce16c143cd 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -5,7 +5,7 @@ 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 @@ def get_view_name(self): # -# 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 00000000000..b59d2050d2e --- /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 d00b7862934..f6d0ac72e82 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -2,18 +2,26 @@ from django.db.models import Q from extras.filters import TagFilter -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet -from utilities.filters import TreeNodeMultipleChoiceFilter -from .models import Tenant, TenantGroup +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter +from .models import * __all__ = ( + 'ContactAssignmentFilterSet', + 'ContactFilterSet', + 'ContactGroupFilterSet', + 'ContactRoleFilterSet', 'TenancyFilterSet', 'TenantFilterSet', 'TenantGroupFilterSet', ) +# +# Tenancy +# + class TenantGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), @@ -23,7 +31,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): field_name='parent__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', - label='Tenant group group (slug)', + label='Tenant group (slug)', ) class Meta: @@ -93,3 +101,90 @@ class TenancyFilterSet(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + + +# +# Contacts +# + +class ContactGroupFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + label='Contact group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=ContactGroup.objects.all(), + to_field_name='slug', + label='Contact group (slug)', + ) + + class Meta: + model = ContactGroup + fields = ['id', 'name', 'slug', 'description'] + + +class ContactRoleFilterSet(OrganizationalModelFilterSet): + + class Meta: + model = ContactRole + fields = ['id', 'name', 'slug'] + + +class ContactFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='group', + lookup_expr='in', + label='Contact group (ID)', + ) + group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='group', + lookup_expr='in', + to_field_name='slug', + label='Contact group (slug)', + ) + tag = TagFilter() + + class Meta: + model = Contact + fields = ['id', 'name', 'title', 'phone', 'email', 'address'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(title__icontains=value) | + Q(phone__icontains=value) | + Q(email__icontains=value) | + Q(address__icontains=value) | + Q(comments__icontains=value) + ) + + +class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): + content_type = ContentTypeFilter() + contact_id = django_filters.ModelMultipleChoiceFilter( + queryset=Contact.objects.all(), + label='Contact (ID)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContactRole.objects.all(), + label='Contact role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=ContactRole.objects.all(), + to_field_name='slug', + label='Contact role (slug)', + ) + + class Meta: + model = ContactAssignment + fields = ['id', 'content_type_id', 'priority'] diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index b2fc7dafd7c..a34b8def1ae 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,68 @@ class Meta: nullable_fields = [ 'group', ] + + +# +# Contacts +# + +class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ContactRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class ContactBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Contact.objects.all(), + widget=forms.MultipleHiddenInput() + ) + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + title = forms.CharField( + max_length=100, + required=False + ) + phone = forms.CharField( + max_length=50, + required=False + ) + email = forms.EmailField( + required=False + ) + address = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments'] diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 335d71ef6d0..73e152a294a 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 6e2eb7fd176..69941701ff4 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -2,9 +2,21 @@ 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 de3a9e515c8..b1506570582 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -1,16 +1,27 @@ +from django import forms + from extras.forms import CustomFieldModelForm from extras.models import Tag -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.forms import ( - BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, + BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea, + StaticSelect, ) __all__ = ( + 'ContactAssignmentForm', + 'ContactForm', + 'ContactGroupForm', + 'ContactRoleForm', 'TenantForm', 'TenantGroupForm', ) +# +# Tenants +# + class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), @@ -45,3 +56,79 @@ class Meta: fieldsets = ( ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), ) + + +# +# Contacts +# + +class ContactGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = ContactGroup + fields = ['parent', 'name', 'slug', 'description'] + + +class ContactRoleForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = ContactRole + fields = ['name', 'slug', 'description'] + + +class ContactForm(BootstrapMixin, CustomFieldModelForm): + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Contact + fields = ( + 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + ) + fieldsets = ( + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')), + ) + widgets = { + 'address': SmallTextarea(attrs={'rows': 3}), + } + + +class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + initial_params={ + 'contacts': '$contact' + } + ) + contact = DynamicModelChoiceField( + queryset=Contact.objects.all(), + query_params={ + 'group_id': '$group' + } + ) + role = DynamicModelChoiceField( + queryset=ContactRole.objects.all() + ) + + class Meta: + model = ContactAssignment + fields = ( + 'group', 'contact', 'role', 'priority', + ) + widgets = { + 'priority': StaticSelect(), + } diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py index f420eb78792..de0a1781a74 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 6f1e27274a3..a16d5108169 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 Meta: 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 00000000000..35e568ab1b3 --- /dev/null +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -0,0 +1,91 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0002_tenant_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='ContactRole', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ContactGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('slug', models.SlugField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.contactgroup')), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('parent', 'name')}, + }, + ), + migrations.CreateModel( + name='Contact', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('title', models.CharField(blank=True, max_length=100)), + ('phone', models.CharField(blank=True, max_length=50)), + ('email', models.EmailField(blank=True, max_length=254)), + ('address', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('group', 'name')}, + }, + ), + migrations.CreateModel( + name='ContactAssignment', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('object_id', models.PositiveIntegerField()), + ('priority', models.CharField(blank=True, max_length=50)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contact')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contactrole')), + ], + options={ + 'ordering': ('priority', 'contact'), + 'unique_together': {('content_type', 'object_id', 'contact', 'role', 'priority')}, + }, + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 4a5b1967e02..c709236e2bb 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, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from extras.utils import extras_features -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet +from .choices import * __all__ = ( + 'ContactAssignment', + 'Contact', + 'ContactGroup', + 'ContactRole', 'Tenant', 'TenantGroup', ) +# +# Tenants +# + @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class TenantGroup(NestedGroupModel): """ @@ -76,6 +86,11 @@ class Tenant(PrimaryModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() clone_fields = [ @@ -90,3 +105,163 @@ def __str__(self): def get_absolute_url(self): return reverse('tenancy:tenant', args=[self.pk]) + + +# +# Contacts +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ContactGroup(NestedGroupModel): + """ + An arbitrary collection of Contacts. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ['name'] + unique_together = ( + ('parent', 'name') + ) + + def get_absolute_url(self): + return reverse('tenancy:contactgroup', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ContactRole(OrganizationalModel): + """ + Functional role for a Contact assigned to an object. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True, + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:contactrole', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Contact(PrimaryModel): + """ + Contact information for a particular object(s) in NetBox. + """ + group = models.ForeignKey( + to='tenancy.ContactGroup', + on_delete=models.SET_NULL, + related_name='contacts', + blank=True, + null=True + ) + name = models.CharField( + max_length=100 + ) + title = models.CharField( + max_length=100, + blank=True + ) + phone = models.CharField( + max_length=50, + blank=True + ) + email = models.EmailField( + blank=True + ) + address = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'group', + ] + + class Meta: + ordering = ['name'] + unique_together = ( + ('group', 'name') + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:contact', args=[self.pk]) + + +@extras_features('webhooks') +class ContactAssignment(ChangeLoggedModel): + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + contact = models.ForeignKey( + to='tenancy.Contact', + on_delete=models.PROTECT, + related_name='assignments' + ) + role = models.ForeignKey( + to='tenancy.ContactRole', + on_delete=models.PROTECT, + related_name='assignments' + ) + priority = models.CharField( + max_length=50, + choices=ContactPriorityChoices, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('priority', 'contact') + unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority') + + def __str__(self): + if self.priority: + return f"{self.contact} ({self.get_priority_display()})" + return str(self.contact) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index f39ca1b18ad..5b254842bea 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,11 +1,15 @@ import django_tables2 as tables from utilities.tables import ( - BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) -from .models import Tenant, TenantGroup +from .models import * __all__ = ( + 'ContactAssignmentTable', + 'ContactGroupTable', + 'ContactRoleTable', + 'ContactTable', 'TenantColumn', 'TenantGroupTable', 'TenantTable', @@ -38,7 +42,7 @@ def value(self, value): # -# Tenant groups +# Tenants # class TenantGroupTable(BaseTable): @@ -59,10 +63,6 @@ class Meta(BaseTable.Meta): default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') -# -# Tenants -# - class TenantTable(BaseTable): pk = ToggleColumn() name = tables.Column( @@ -80,3 +80,82 @@ class Meta(BaseTable.Meta): model = Tenant fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags') default_columns = ('pk', 'name', 'group', 'description') + + +# +# Contacts +# + +class ContactGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + contact_count = LinkedCountColumn( + viewname='tenancy:contact_list', + url_params={'role_id': 'pk'}, + verbose_name='Contacts' + ) + actions = ButtonsColumn(ContactGroup) + + class Meta(BaseTable.Meta): + model = ContactGroup + fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') + + +class ContactRoleTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + actions = ButtonsColumn(ContactRole) + + class Meta(BaseTable.Meta): + model = ContactRole + fields = ('pk', 'name', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'description', 'actions') + + +class ContactTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + assignment_count = tables.Column( + verbose_name='Assignments' + ) + tags = TagColumn( + url_name='tenancy:tenant_list' + ) + + class Meta(BaseTable.Meta): + model = Contact + fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags') + default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') + + +class ContactAssignmentTable(BaseTable): + pk = ToggleColumn() + content_type = ContentTypeColumn( + verbose_name='Object Type' + ) + object = tables.Column( + linkify=True, + orderable=False + ) + contact = tables.Column( + linkify=True + ) + role = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = ContactAssignment + fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') + default_columns = ('pk', 'object', 'contact', 'role', 'priority') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 5a3c2c1b046..c7c6cf846b5 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 @@ def setUpTestData(cls): '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 fd4a0bd7674..86170734c39 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 @@ def test_group(self): 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 f45afc3020f..fb7ff3ce3bd 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 @@ def setUpTestData(cls): 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 a1f46c7ec2b..14047603d19 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -3,7 +3,7 @@ from extras.views import ObjectChangeLogView, ObjectJournalView from utilities.views import SlugRedirectView from . import views -from .models import Tenant, TenantGroup +from .models import * app_name = 'tenancy' urlpatterns = [ @@ -32,4 +32,44 @@ 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}), + + # Contact assignments + path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), + path('contact-assignments//edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'), + path('contact-assignments//delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'), + ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 0b28a62d282..cdbaebdb1b5 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,11 +1,16 @@ +from django.contrib.contenttypes.models import ContentType +from django.http import Http404 +from django.shortcuts import get_object_or_404 + from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic from utilities.tables import paginate_table +from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables -from .models import Tenant, TenantGroup +from .models import * # @@ -140,3 +145,217 @@ class TenantBulkDeleteView(generic.BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filtersets.TenantFilterSet table = tables.TenantTable + + +# +# Contact groups +# + +class ContactGroupListView(generic.ObjectListView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + filterset = filtersets.ContactGroupFilterSet + filterset_form = forms.ContactGroupFilterForm + table = tables.ContactGroupTable + + +class ContactGroupView(generic.ObjectView): + queryset = ContactGroup.objects.all() + + def get_extra_context(self, request, instance): + contacts = Contact.objects.restrict(request.user, 'view').filter( + group=instance + ) + contacts_table = tables.ContactTable(contacts, exclude=('group',)) + paginate_table(contacts_table, request) + + return { + 'contacts_table': contacts_table, + } + + +class ContactGroupEditView(generic.ObjectEditView): + queryset = ContactGroup.objects.all() + model_form = forms.ContactGroupForm + + +class ContactGroupDeleteView(generic.ObjectDeleteView): + queryset = ContactGroup.objects.all() + + +class ContactGroupBulkImportView(generic.BulkImportView): + queryset = ContactGroup.objects.all() + model_form = forms.ContactGroupCSVForm + table = tables.ContactGroupTable + + +class ContactGroupBulkEditView(generic.BulkEditView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + filterset = filtersets.ContactGroupFilterSet + table = tables.ContactGroupTable + form = forms.ContactGroupBulkEditForm + + +class ContactGroupBulkDeleteView(generic.BulkDeleteView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + table = tables.ContactGroupTable + + +# +# Contact roles +# + +class ContactRoleListView(generic.ObjectListView): + queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet + filterset_form = forms.ContactRoleFilterForm + table = tables.ContactRoleTable + + +class ContactRoleView(generic.ObjectView): + queryset = ContactRole.objects.all() + + def get_extra_context(self, request, instance): + contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( + role=instance + ) + contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table.columns.hide('role') + paginate_table(contacts_table, request) + + return { + 'contacts_table': contacts_table, + 'assignment_count': ContactAssignment.objects.filter(role=instance).count(), + } + + +class ContactRoleEditView(generic.ObjectEditView): + queryset = ContactRole.objects.all() + model_form = forms.ContactRoleForm + + +class ContactRoleDeleteView(generic.ObjectDeleteView): + queryset = ContactRole.objects.all() + + +class ContactRoleBulkImportView(generic.BulkImportView): + queryset = ContactRole.objects.all() + model_form = forms.ContactRoleCSVForm + table = tables.ContactRoleTable + + +class ContactRoleBulkEditView(generic.BulkEditView): + queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet + table = tables.ContactRoleTable + form = forms.ContactRoleBulkEditForm + + +class ContactRoleBulkDeleteView(generic.BulkDeleteView): + queryset = ContactRole.objects.all() + table = tables.ContactRoleTable + + +# +# Contacts +# + +class ContactListView(generic.ObjectListView): + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) + filterset = filtersets.ContactFilterSet + filterset_form = forms.ContactFilterForm + table = tables.ContactTable + + +class ContactView(generic.ObjectView): + queryset = Contact.objects.all() + + def get_extra_context(self, request, instance): + contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( + contact=instance + ) + contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table.columns.hide('contact') + paginate_table(contacts_table, request) + + return { + 'contacts_table': contacts_table, + 'assignment_count': ContactAssignment.objects.filter(contact=instance).count(), + } + + +class ContactEditView(generic.ObjectEditView): + queryset = Contact.objects.all() + model_form = forms.ContactForm + + +class ContactDeleteView(generic.ObjectDeleteView): + queryset = Contact.objects.all() + + +class ContactBulkImportView(generic.BulkImportView): + queryset = Contact.objects.all() + model_form = forms.ContactCSVForm + table = tables.ContactTable + + +class ContactBulkEditView(generic.BulkEditView): + queryset = Contact.objects.prefetch_related('group') + filterset = filtersets.ContactFilterSet + table = tables.ContactTable + form = forms.ContactBulkEditForm + + +class ContactBulkDeleteView(generic.BulkDeleteView): + queryset = Contact.objects.prefetch_related('group') + filterset = filtersets.ContactFilterSet + table = tables.ContactTable + + +# +# Contact assignments +# + +class ContactAssignmentEditView(generic.ObjectEditView): + queryset = ContactAssignment.objects.all() + model_form = forms.ContactAssignmentForm + + def alter_obj(self, instance, request, args, kwargs): + if not instance.pk: + # Assign the object based on URL kwargs + try: + app_label, model = request.GET.get('content_type').split('.') + except (AttributeError, ValueError): + raise Http404("Content type not specified") + content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + return instance + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) + + +class ContactAssignmentDeleteView(generic.ObjectDeleteView): + queryset = ContactAssignment.objects.all() + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3408cedbc27..d91a39549b8 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -81,12 +81,17 @@ class ClusterGroup(OrganizationalModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='cluster_group' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) objects = RestrictedQuerySet.as_manager() @@ -142,12 +147,17 @@ class Cluster(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='cluster' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) objects = RestrictedQuerySet.as_manager() @@ -268,6 +278,11 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): blank=True ) + # Generic relation + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = ConfigContextModelQuerySet.as_manager() clone_fields = [