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

Closes #1344: Contact objects
This commit is contained in:
Jeremy Stretch 2021-10-19 08:46:13 -04:00 committed by GitHub
commit ba7361bdc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1971 additions and 75 deletions

View File

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

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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'

View File

@ -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'
)

View File

@ -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'
)

View File

@ -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'
)

View File

@ -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'
)

View File

@ -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'
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,10 @@
from django.contrib.auth.models import ContentType
from rest_framework import serializers
from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
from tenancy.models import Tenant, TenantGroup
from netbox.api import ChoiceField, ContentTypeField
from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer
from tenancy.choices import ContactPriorityChoices
from tenancy.models import *
from .nested_serializers import *
@ -43,3 +46,59 @@ class TenantSerializer(PrimaryModelSerializer):
'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
]
#
# Contacts
#
class ContactGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
parent = NestedContactGroupSerializer(required=False, allow_null=True)
contact_count = serializers.IntegerField(read_only=True)
class Meta:
model = ContactGroup
fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
'contact_count', '_depth',
]
class ContactRoleSerializer(OrganizationalModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
class Meta:
model = ContactRole
fields = [
'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
]
class ContactSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
group = NestedContactGroupSerializer(required=False, allow_null=True)
class Meta:
model = Contact
fields = [
'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
class ContactAssignmentSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
content_type = ContentTypeField(
queryset=ContentType.objects.all()
)
contact = NestedContactSerializer()
role = NestedContactRoleSerializer(required=False, allow_null=True)
priority = ChoiceField(choices=ContactPriorityChoices, required=False)
class Meta:
model = ContactAssignment
fields = [
'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'priority', 'created',
'last_updated',
]

View File

@ -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

View File

@ -5,7 +5,7 @@ from dcim.models import Device, Rack, Site
from extras.api.views import CustomFieldModelViewSet
from ipam.models import IPAddress, Prefix, VLAN, VRF
from tenancy import filtersets
from tenancy.models import Tenant, TenantGroup
from tenancy.models import *
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
@ -20,7 +20,7 @@ class TenancyRootView(APIRootView):
#
# Tenant Groups
# Tenants
#
class TenantGroupViewSet(CustomFieldModelViewSet):
@ -35,10 +35,6 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.TenantGroupFilterSet
#
# Tenants
#
class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.prefetch_related(
'group', 'tags'
@ -55,3 +51,41 @@ class TenantViewSet(CustomFieldModelViewSet):
)
serializer_class = serializers.TenantSerializer
filterset_class = filtersets.TenantFilterSet
#
# Contacts
#
class ContactGroupViewSet(CustomFieldModelViewSet):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
Contact,
'group',
'contact_count',
cumulative=True
)
serializer_class = serializers.ContactGroupSerializer
filterset_class = filtersets.ContactGroupFilterSet
class ContactRoleViewSet(CustomFieldModelViewSet):
queryset = ContactRole.objects.all()
serializer_class = serializers.ContactRoleSerializer
filterset_class = filtersets.ContactRoleFilterSet
class ContactViewSet(CustomFieldModelViewSet):
queryset = Contact.objects.prefetch_related(
'group', 'tags'
)
serializer_class = serializers.ContactSerializer
filterset_class = filtersets.ContactFilterSet
class ContactAssignmentViewSet(CustomFieldModelViewSet):
queryset = ContactAssignment.objects.prefetch_related(
'contact', 'role'
)
serializer_class = serializers.ContactAssignmentSerializer
filterset_class = filtersets.ContactAssignmentFilterSet

19
netbox/tenancy/choices.py Normal file
View File

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

View File

@ -2,18 +2,26 @@ import django_filters
from django.db.models import Q
from extras.filters import TagFilter
from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from .models import Tenant, TenantGroup
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from .models import *
__all__ = (
'ContactAssignmentFilterSet',
'ContactFilterSet',
'ContactGroupFilterSet',
'ContactRoleFilterSet',
'TenancyFilterSet',
'TenantFilterSet',
'TenantGroupFilterSet',
)
#
# Tenancy
#
class TenantGroupFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
@ -23,7 +31,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet):
field_name='parent__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
label='Tenant group group (slug)',
label='Tenant group (slug)',
)
class Meta:
@ -93,3 +101,90 @@ class TenancyFilterSet(django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
#
# Contacts
#
class ContactGroupFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
label='Contact group (ID)',
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=ContactGroup.objects.all(),
to_field_name='slug',
label='Contact group (slug)',
)
class Meta:
model = ContactGroup
fields = ['id', 'name', 'slug', 'description']
class ContactRoleFilterSet(OrganizationalModelFilterSet):
class Meta:
model = ContactRole
fields = ['id', 'name', 'slug']
class ContactFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
field_name='group',
lookup_expr='in',
label='Contact group (ID)',
)
group = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
field_name='group',
lookup_expr='in',
to_field_name='slug',
label='Contact group (slug)',
)
tag = TagFilter()
class Meta:
model = Contact
fields = ['id', 'name', 'title', 'phone', 'email', 'address']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(title__icontains=value) |
Q(phone__icontains=value) |
Q(email__icontains=value) |
Q(address__icontains=value) |
Q(comments__icontains=value)
)
class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
content_type = ContentTypeFilter()
contact_id = django_filters.ModelMultipleChoiceFilter(
queryset=Contact.objects.all(),
label='Contact (ID)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContactRole.objects.all(),
label='Contact role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=ContactRole.objects.all(),
to_field_name='slug',
label='Contact role (slug)',
)
class Meta:
model = ContactAssignment
fields = ['id', 'content_type_id', 'priority']

View File

@ -1,15 +1,22 @@
from django import forms
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from tenancy.models import Tenant, TenantGroup
from tenancy.models import *
from utilities.forms import BootstrapMixin, DynamicModelChoiceField
__all__ = (
'ContactBulkEditForm',
'ContactGroupBulkEditForm',
'ContactRoleBulkEditForm',
'TenantBulkEditForm',
'TenantGroupBulkEditForm',
)
#
# Tenants
#
class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
@ -42,3 +49,68 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
nullable_fields = [
'group',
]
#
# Contacts
#
class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ContactGroup.objects.all(),
widget=forms.MultipleHiddenInput
)
parent = DynamicModelChoiceField(
queryset=ContactGroup.objects.all(),
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['parent', 'description']
class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ContactRole.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['description']
class ContactBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Contact.objects.all(),
widget=forms.MultipleHiddenInput()
)
group = DynamicModelChoiceField(
queryset=ContactGroup.objects.all(),
required=False
)
title = forms.CharField(
max_length=100,
required=False
)
phone = forms.CharField(
max_length=50,
required=False
)
email = forms.EmailField(
required=False
)
address = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments']

View File

@ -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')

View File

@ -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)

View File

@ -1,16 +1,27 @@
from django import forms
from extras.forms import CustomFieldModelForm
from extras.models import Tag
from tenancy.models import Tenant, TenantGroup
from tenancy.models import *
from utilities.forms import (
BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
StaticSelect,
)
__all__ = (
'ContactAssignmentForm',
'ContactForm',
'ContactGroupForm',
'ContactRoleForm',
'TenantForm',
'TenantGroupForm',
)
#
# Tenants
#
class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
@ -45,3 +56,79 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
fieldsets = (
('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
)
#
# Contacts
#
class ContactGroupForm(BootstrapMixin, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=ContactGroup.objects.all(),
required=False
)
slug = SlugField()
class Meta:
model = ContactGroup
fields = ['parent', 'name', 'slug', 'description']
class ContactRoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
class Meta:
model = ContactRole
fields = ['name', 'slug', 'description']
class ContactForm(BootstrapMixin, CustomFieldModelForm):
group = DynamicModelChoiceField(
queryset=ContactGroup.objects.all(),
required=False
)
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Contact
fields = (
'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
)
fieldsets = (
('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')),
)
widgets = {
'address': SmallTextarea(attrs={'rows': 3}),
}
class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
group = DynamicModelChoiceField(
queryset=ContactGroup.objects.all(),
required=False,
initial_params={
'contacts': '$contact'
}
)
contact = DynamicModelChoiceField(
queryset=Contact.objects.all(),
query_params={
'group_id': '$group'
}
)
role = DynamicModelChoiceField(
queryset=ContactRole.objects.all()
)
class Meta:
model = ContactAssignment
fields = (
'group', 'contact', 'role', 'priority',
)
widgets = {
'priority': StaticSelect(),
}

View File

@ -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)

View File

@ -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

View File

@ -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')},
},
),
]

View File

@ -1,19 +1,29 @@
from django.core.exceptions import ValidationError
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
from .choices import *
__all__ = (
'ContactAssignment',
'Contact',
'ContactGroup',
'ContactRole',
'Tenant',
'TenantGroup',
)
#
# Tenants
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class TenantGroup(NestedGroupModel):
"""
@ -76,6 +86,11 @@ class Tenant(PrimaryModel):
blank=True
)
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
@ -90,3 +105,163 @@ class Tenant(PrimaryModel):
def get_absolute_url(self):
return reverse('tenancy:tenant', args=[self.pk])
#
# Contacts
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class ContactGroup(NestedGroupModel):
"""
An arbitrary collection of Contacts.
"""
name = models.CharField(
max_length=100
)
slug = models.SlugField(
max_length=100
)
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
description = models.CharField(
max_length=200,
blank=True
)
class Meta:
ordering = ['name']
unique_together = (
('parent', 'name')
)
def get_absolute_url(self):
return reverse('tenancy:contactgroup', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class ContactRole(OrganizationalModel):
"""
Functional role for a Contact assigned to an object.
"""
name = models.CharField(
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
description = models.CharField(
max_length=200,
blank=True,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('tenancy:contactrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Contact(PrimaryModel):
"""
Contact information for a particular object(s) in NetBox.
"""
group = models.ForeignKey(
to='tenancy.ContactGroup',
on_delete=models.SET_NULL,
related_name='contacts',
blank=True,
null=True
)
name = models.CharField(
max_length=100
)
title = models.CharField(
max_length=100,
blank=True
)
phone = models.CharField(
max_length=50,
blank=True
)
email = models.EmailField(
blank=True
)
address = models.CharField(
max_length=200,
blank=True
)
comments = models.TextField(
blank=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'group',
]
class Meta:
ordering = ['name']
unique_together = (
('group', 'name')
)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('tenancy:contact', args=[self.pk])
@extras_features('webhooks')
class ContactAssignment(ChangeLoggedModel):
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
object = GenericForeignKey(
ct_field='content_type',
fk_field='object_id'
)
contact = models.ForeignKey(
to='tenancy.Contact',
on_delete=models.PROTECT,
related_name='assignments'
)
role = models.ForeignKey(
to='tenancy.ContactRole',
on_delete=models.PROTECT,
related_name='assignments'
)
priority = models.CharField(
max_length=50,
choices=ContactPriorityChoices,
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('priority', 'contact')
unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority')
def __str__(self):
if self.priority:
return f"{self.contact} ({self.get_priority_display()})"
return str(self.contact)

View File

@ -1,11 +1,15 @@
import django_tables2 as tables
from utilities.tables import (
BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
)
from .models import Tenant, TenantGroup
from .models import *
__all__ = (
'ContactAssignmentTable',
'ContactGroupTable',
'ContactRoleTable',
'ContactTable',
'TenantColumn',
'TenantGroupTable',
'TenantTable',
@ -38,7 +42,7 @@ class TenantColumn(tables.TemplateColumn):
#
# Tenant groups
# Tenants
#
class TenantGroupTable(BaseTable):
@ -59,10 +63,6 @@ class TenantGroupTable(BaseTable):
default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
#
# Tenants
#
class TenantTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
@ -80,3 +80,82 @@ class TenantTable(BaseTable):
model = Tenant
fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'group', 'description')
#
# Contacts
#
class ContactGroupTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn(
linkify=True
)
contact_count = LinkedCountColumn(
viewname='tenancy:contact_list',
url_params={'role_id': 'pk'},
verbose_name='Contacts'
)
actions = ButtonsColumn(ContactGroup)
class Meta(BaseTable.Meta):
model = ContactGroup
fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
class ContactRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
actions = ButtonsColumn(ContactRole)
class Meta(BaseTable.Meta):
model = ContactRole
fields = ('pk', 'name', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'description', 'actions')
class ContactTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
comments = MarkdownColumn()
assignment_count = tables.Column(
verbose_name='Assignments'
)
tags = TagColumn(
url_name='tenancy:tenant_list'
)
class Meta(BaseTable.Meta):
model = Contact
fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags')
default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email')
class ContactAssignmentTable(BaseTable):
pk = ToggleColumn()
content_type = ContentTypeColumn(
verbose_name='Object Type'
)
object = tables.Column(
linkify=True,
orderable=False
)
contact = tables.Column(
linkify=True
)
role = tables.Column(
linkify=True
)
class Meta(BaseTable.Meta):
model = ContactAssignment
fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
default_columns = ('pk', 'object', 'contact', 'role', 'priority')

View File

@ -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,
},
]

View File

@ -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)

View File

@ -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,
}

View File

@ -3,7 +3,7 @@ from django.urls import path
from extras.views import ObjectChangeLogView, ObjectJournalView
from utilities.views import SlugRedirectView
from . import views
from .models import Tenant, TenantGroup
from .models import *
app_name = 'tenancy'
urlpatterns = [
@ -32,4 +32,44 @@ urlpatterns = [
path('tenants/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
path('tenants/<int:pk>/journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}),
# Contact groups
path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'),
path('contact-groups/add/', views.ContactGroupEditView.as_view(), name='contactgroup_add'),
path('contact-groups/import/', views.ContactGroupBulkImportView.as_view(), name='contactgroup_import'),
path('contact-groups/edit/', views.ContactGroupBulkEditView.as_view(), name='contactgroup_bulk_edit'),
path('contact-groups/delete/', views.ContactGroupBulkDeleteView.as_view(), name='contactgroup_bulk_delete'),
path('contact-groups/<int:pk>/', views.ContactGroupView.as_view(), name='contactgroup'),
path('contact-groups/<int:pk>/edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'),
path('contact-groups/<int:pk>/delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'),
path('contact-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}),
# Contact roles
path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'),
path('contact-roles/add/', views.ContactRoleEditView.as_view(), name='contactrole_add'),
path('contact-roles/import/', views.ContactRoleBulkImportView.as_view(), name='contactrole_import'),
path('contact-roles/edit/', views.ContactRoleBulkEditView.as_view(), name='contactrole_bulk_edit'),
path('contact-roles/delete/', views.ContactRoleBulkDeleteView.as_view(), name='contactrole_bulk_delete'),
path('contact-roles/<int:pk>/', views.ContactRoleView.as_view(), name='contactrole'),
path('contact-roles/<int:pk>/edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'),
path('contact-roles/<int:pk>/delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'),
path('contact-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}),
# Contacts
path('contacts/', views.ContactListView.as_view(), name='contact_list'),
path('contacts/add/', views.ContactEditView.as_view(), name='contact_add'),
path('contacts/import/', views.ContactBulkImportView.as_view(), name='contact_import'),
path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'),
path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'),
path('contacts/<int:pk>/', views.ContactView.as_view(), name='contact'),
path('contacts/<slug:slug>/', SlugRedirectView.as_view(), kwargs={'model': Contact}),
path('contacts/<int:pk>/edit/', views.ContactEditView.as_view(), name='contact_edit'),
path('contacts/<int:pk>/delete/', views.ContactDeleteView.as_view(), name='contact_delete'),
path('contacts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
path('contacts/<int:pk>/journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}),
# Contact assignments
path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),
path('contact-assignments/<int:pk>/edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'),
path('contact-assignments/<int:pk>/delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'),
]

View File

@ -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)

View File

@ -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 = [