1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00
Files
netbox-community-netbox/netbox/extras/models/models.py

760 lines
23 KiB
Python
Raw Normal View History

import json
import uuid
from django.conf import settings
2021-10-25 14:42:20 -04:00
from django.contrib import admin
from django.contrib.auth.models import User
2016-08-12 17:20:01 -04:00
from django.contrib.contenttypes.fields import GenericForeignKey
2016-03-01 11:23:03 -05:00
from django.contrib.contenttypes.models import ContentType
2021-10-25 14:42:20 -04:00
from django.core.cache import cache
2016-08-12 17:20:01 -04:00
from django.core.validators import ValidationError
2016-03-01 11:23:03 -05:00
from django.db import models
from django.http import HttpResponse, QueryDict
from django.urls import reverse
2020-07-03 11:55:04 -04:00
from django.utils import timezone
2021-10-22 17:15:08 -04:00
from django.utils.formats import date_format
from django.utils.translation import gettext as _
from rest_framework.utils.encoders import JSONEncoder
import django_rq
2016-03-01 11:23:03 -05:00
2020-05-07 16:59:27 -04:00
from extras.choices import *
from extras.constants import *
2021-10-22 17:15:08 -04:00
from extras.conditions import ConditionSet
2022-01-19 15:16:10 -05:00
from extras.utils import FeatureQuery, image_upload
from netbox.config import get_config
2022-12-08 09:58:52 -05:00
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
)
from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2
__all__ = (
2021-10-25 14:42:20 -04:00
'ConfigRevision',
'CustomLink',
'ExportTemplate',
'ImageAttachment',
'JobResult',
'JournalEntry',
'Report',
'SavedFilter',
'Script',
'Webhook',
)
2022-01-19 15:16:10 -05:00
class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
Each Webhook can be limited to firing only on certain actions or certain object types.
"""
content_types = models.ManyToManyField(
to=ContentType,
related_name='webhooks',
verbose_name='Object types',
2020-03-16 11:58:35 -04:00
limit_choices_to=FeatureQuery('webhooks'),
help_text=_("The object(s) to which this Webhook applies.")
)
name = models.CharField(
max_length=150,
unique=True
)
type_create = models.BooleanField(
default=False,
help_text=_("Call this webhook when a matching object is created.")
)
type_update = models.BooleanField(
default=False,
help_text=_("Call this webhook when a matching object is updated.")
)
type_delete = models.BooleanField(
default=False,
help_text=_("Call this webhook when a matching object is deleted.")
)
payload_url = models.CharField(
max_length=500,
verbose_name='URL',
help_text=_('This URL will be called using the HTTP method defined when the webhook is called. '
'Jinja2 template processing is supported with the same context as the request body.')
)
enabled = models.BooleanField(
default=True
)
2020-02-24 20:42:24 -05:00
http_method = models.CharField(
max_length=30,
choices=WebhookHttpMethodChoices,
default=WebhookHttpMethodChoices.METHOD_POST,
verbose_name='HTTP method'
)
http_content_type = models.CharField(
max_length=100,
default=HTTP_CONTENT_TYPE_JSON,
verbose_name='HTTP content type',
help_text=_('The complete list of official content types is available '
'<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.')
)
additional_headers = models.TextField(
blank=True,
help_text=_("User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. "
"Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is "
"supported with the same context as the request body (below).")
)
body_template = models.TextField(
blank=True,
help_text=_('Jinja2 template for a custom request body. If blank, a JSON object representing the change will be '
'included. Available context data includes: <code>event</code>, <code>model</code>, '
'<code>timestamp</code>, <code>username</code>, <code>request_id</code>, and <code>data</code>.')
)
secret = models.CharField(
max_length=255,
blank=True,
help_text=_("When provided, the request will include a 'X-Hook-Signature' "
"header containing a HMAC hex digest of the payload body using "
"the secret as the key. The secret is not transmitted in "
"the request.")
)
2021-10-22 17:15:08 -04:00
conditions = models.JSONField(
blank=True,
null=True,
help_text=_("A set of conditions which determine whether the webhook will be generated.")
2021-10-22 17:15:08 -04:00
)
ssl_verification = models.BooleanField(
default=True,
2018-07-16 13:54:50 -04:00
verbose_name='SSL verification',
help_text=_("Enable SSL certificate verification. Disable with caution!")
)
ca_file_path = models.CharField(
max_length=4096,
null=True,
blank=True,
verbose_name='CA File Path',
help_text=_('The specific CA certificate file to use for SSL verification. '
'Leave blank to use the system defaults.')
)
class Meta:
ordering = ('name',)
constraints = (
models.UniqueConstraint(
fields=('payload_url', 'type_create', 'type_update', 'type_delete'),
name='%(app_label)s_%(class)s_unique_payload_url_types'
),
)
def __str__(self):
return self.name
2021-06-23 21:24:23 -04:00
def get_absolute_url(self):
return reverse('extras:webhook', args=[self.pk])
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/extras/webhook/'
def clean(self):
super().clean()
# At least one action type must be selected
if not self.type_create and not self.type_delete and not self.type_update:
2021-10-22 17:15:08 -04:00
raise ValidationError("At least one type must be selected: create, update, and/or delete.")
if self.conditions:
try:
ConditionSet(self.conditions)
except ValueError as e:
raise ValidationError({'conditions': e})
# CA file path requires SSL verification enabled
if not self.ssl_verification and self.ca_file_path:
raise ValidationError({
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
})
def render_headers(self, context):
"""
Render additional_headers and return a dict of Header: Value pairs.
"""
if not self.additional_headers:
return {}
ret = {}
data = render_jinja2(self.additional_headers, context)
for line in data.splitlines():
header, value = line.split(':', 1)
ret[header.strip()] = value.strip()
return ret
def render_body(self, context):
"""
Render the body template, if defined. Otherwise, jump the context as a JSON object.
"""
if self.body_template:
return render_jinja2(self.body_template, context)
else:
return json.dumps(context, cls=JSONEncoder)
def render_payload_url(self, context):
"""
Render the payload URL.
"""
return render_jinja2(self.payload_url, context)
class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
2019-04-15 17:12:41 -04:00
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context.
"""
content_types = models.ManyToManyField(
2019-04-15 17:12:41 -04:00
to=ContentType,
related_name='custom_links',
help_text=_('The object type(s) to which this link applies.')
2019-04-15 17:12:41 -04:00
)
name = models.CharField(
max_length=100,
unique=True
)
enabled = models.BooleanField(
default=True
)
link_text = models.TextField(
help_text=_("Jinja2 template code for link text")
2019-04-15 17:12:41 -04:00
)
link_url = models.TextField(
verbose_name='Link URL',
help_text=_("Jinja2 template code for link URL")
2019-04-15 17:12:41 -04:00
)
weight = models.PositiveSmallIntegerField(
default=100
)
group_name = models.CharField(
max_length=50,
2019-04-15 21:29:02 -04:00
blank=True,
help_text=_("Links with the same group will appear as a dropdown menu")
2019-04-15 17:12:41 -04:00
)
button_class = models.CharField(
max_length=30,
choices=CustomLinkButtonClassChoices,
2021-12-29 20:28:12 -05:00
default=CustomLinkButtonClassChoices.DEFAULT,
help_text=_("The class of the first link in a group will be used for the dropdown button")
2019-04-15 17:12:41 -04:00
)
new_window = models.BooleanField(
default=False,
help_text=_("Force link to open in a new window")
2019-04-15 17:12:41 -04:00
)
clone_fields = (
'enabled', 'weight', 'group_name', 'button_class', 'new_window',
)
2019-04-15 17:12:41 -04:00
class Meta:
ordering = ['group_name', 'weight', 'name']
def __str__(self):
return self.name
2021-06-23 17:09:15 -04:00
def get_absolute_url(self):
return reverse('extras:customlink', args=[self.pk])
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/extras/customlink/'
def render(self, context):
"""
Render the CustomLink given the provided context, and return the text, link, and link_target.
:param context: The context passed to Jinja2
"""
text = render_jinja2(self.link_text, context)
if not text:
return {}
link = render_jinja2(self.link_url, context)
link_target = ' target="_blank"' if self.new_window else ''
return {
'text': text,
'link': link,
'link_target': link_target,
}
2019-04-15 17:12:41 -04:00
2022-01-19 15:16:10 -05:00
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
2018-03-30 13:57:26 -04:00
to=ContentType,
related_name='export_templates',
help_text=_('The object type(s) to which this template applies.')
2018-03-30 13:57:26 -04:00
)
name = models.CharField(
max_length=100
)
description = models.CharField(
max_length=200,
blank=True
)
template_code = models.TextField(
help_text=_('Jinja2 template code. The list of objects being exported is passed as a context variable named '
'<code>queryset</code>.')
)
2018-03-30 13:57:26 -04:00
mime_type = models.CharField(
max_length=50,
blank=True,
verbose_name='MIME type',
help_text=_('Defaults to <code>text/plain</code>')
2018-03-30 13:57:26 -04:00
)
file_extension = models.CharField(
max_length=15,
blank=True,
help_text=_('Extension to append to the rendered filename')
2018-03-30 13:57:26 -04:00
)
as_attachment = models.BooleanField(
default=True,
help_text=_("Download file as attachment")
)
2016-03-01 11:23:03 -05:00
class Meta:
ordering = ('name',)
2016-03-01 11:23:03 -05:00
def __str__(self):
return self.name
2021-06-23 20:39:35 -04:00
def get_absolute_url(self):
return reverse('extras:exporttemplate', args=[self.pk])
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/extras/exporttemplate/'
def clean(self):
super().clean()
if self.name.lower() == 'table':
raise ValidationError({
'name': f'"{self.name}" is a reserved name. Please choose a different name.'
})
2016-03-01 11:23:03 -05:00
def render(self, queryset):
2016-03-01 11:23:03 -05:00
"""
Render the contents of the template.
2016-03-01 11:23:03 -05:00
"""
context = {
'queryset': queryset
}
output = render_jinja2(self.template_code, context)
2018-02-02 11:34:31 -05:00
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
2018-02-02 11:34:31 -05:00
return output
def render_to_response(self, queryset):
"""
Render the template to an HTTP response, delivered as a named file attachment
"""
output = self.render(queryset)
mime_type = 'text/plain' if not self.mime_type else self.mime_type
2018-02-02 11:34:31 -05:00
# Build the response
response = HttpResponse(output, content_type=mime_type)
if self.as_attachment:
basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
extension = f'.{self.file_extension}' if self.file_extension else ''
filename = f'netbox_{basename}{extension}'
response['Content-Disposition'] = f'attachment; filename="{filename}"'
2018-02-02 11:34:31 -05:00
2016-03-01 11:23:03 -05:00
return response
2016-04-08 14:57:54 -04:00
class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
"""
A set of predefined keyword parameters that can be reused to filter for specific objects.
"""
content_types = models.ManyToManyField(
to=ContentType,
related_name='saved_filters',
help_text=_('The object type(s) to which this filter applies.')
)
name = models.CharField(
max_length=100,
unique=True
)
2022-11-15 10:44:12 -05:00
slug = models.SlugField(
max_length=100,
unique=True
)
description = models.CharField(
max_length=200,
blank=True
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
blank=True,
null=True
)
weight = models.PositiveSmallIntegerField(
default=100
)
enabled = models.BooleanField(
default=True
)
shared = models.BooleanField(
default=True
)
parameters = models.JSONField()
clone_fields = (
'enabled', 'weight',
)
class Meta:
ordering = ('weight', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:savedfilter', args=[self.pk])
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/extras/savedfilter/'
def clean(self):
super().clean()
# Verify that `parameters` is a JSON object
if type(self.parameters) is not dict:
raise ValidationError(
{'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'}
)
@property
def url_params(self):
qd = QueryDict(mutable=True)
qd.update(self.parameters)
return qd.urlencode()
2022-01-19 15:16:10 -05:00
class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
"""
An uploaded image which is associated with an object.
"""
2018-03-30 13:57:26 -04:00
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE
)
object_id = models.PositiveBigIntegerField()
2018-03-30 13:57:26 -04:00
parent = GenericForeignKey(
ct_field='content_type',
fk_field='object_id'
)
image = models.ImageField(
upload_to=image_upload,
height_field='image_height',
width_field='image_width'
)
image_height = models.PositiveSmallIntegerField()
image_width = models.PositiveSmallIntegerField()
2018-03-30 13:57:26 -04:00
name = models.CharField(
max_length=50,
blank=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = ('content_type', 'object_id')
class Meta:
ordering = ('name', 'pk') # name may be non-unique
def __str__(self):
if self.name:
return self.name
filename = self.image.name.rsplit('/', 1)[-1]
return filename.split('_', 2)[2]
def delete(self, *args, **kwargs):
_name = self.image.name
super().delete(*args, **kwargs)
# Delete file from disk
self.image.delete(save=False)
# Deleting the file erases its name. We restore the image's filename here in case we still need to reference it
# before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
self.image.name = _name
@property
def size(self):
"""
Wrapper around `image.size` to suppress an OSError in case the file is inaccessible. Also opportunistically
catch other exceptions that we know other storage back-ends to throw.
"""
expected_exceptions = [OSError]
try:
from botocore.exceptions import ClientError
expected_exceptions.append(ClientError)
except ImportError:
pass
2019-11-03 14:16:12 +03:00
try:
return self.image.size
except tuple(expected_exceptions):
return None
def to_objectchange(self, action):
2022-01-26 20:25:23 -05:00
objectchange = super().to_objectchange(action)
objectchange.related_object = self.parent
return objectchange
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ChangeLoggedModel):
"""
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
"""
assigned_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE
)
assigned_object_id = models.PositiveBigIntegerField()
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
created_by = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
blank=True,
null=True
)
2021-03-17 12:51:39 -04:00
kind = models.CharField(
max_length=30,
choices=JournalEntryKindChoices,
default=JournalEntryKindChoices.KIND_INFO
)
comments = models.TextField()
class Meta:
ordering = ('-created',)
2021-03-16 15:57:23 -04:00
verbose_name_plural = 'journal entries'
def __str__(self):
created = timezone.localtime(self.created)
return f"{date_format(created, format='SHORT_DATETIME_FORMAT')} ({self.get_kind_display()})"
def get_absolute_url(self):
return reverse('extras:journalentry', args=[self.pk])
2021-03-17 12:51:39 -04:00
def clean(self):
super().clean()
# Prevent the creation of journal entries on unsupported models
permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
if self.assigned_object_type not in permitted_types:
raise ValidationError(f"Journaling is not supported for this object type ({self.assigned_object_type}).")
def get_kind_color(self):
return JournalEntryKindChoices.colors.get(self.kind)
class JobResult(models.Model):
2017-09-21 16:32:05 -04:00
"""
This model stores the results from running a user-defined report.
"""
name = models.CharField(
max_length=255
)
obj_type = models.ForeignKey(
to=ContentType,
related_name='job_results',
verbose_name='Object types',
limit_choices_to=FeatureQuery('job_results'),
help_text=_("The object type to which this job result applies"),
on_delete=models.CASCADE,
2018-03-30 13:57:26 -04:00
)
created = models.DateTimeField(
auto_now_add=True
)
scheduled = models.DateTimeField(
null=True,
blank=True
)
2022-11-15 14:38:58 -05:00
started = models.DateTimeField(
null=True,
blank=True
)
completed = models.DateTimeField(
null=True,
blank=True
)
2018-03-30 13:57:26 -04:00
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
status = models.CharField(
max_length=30,
choices=JobResultStatusChoices,
default=JobResultStatusChoices.STATUS_PENDING
)
data = models.JSONField(
null=True,
blank=True
)
job_id = models.UUIDField(
unique=True
)
2017-09-21 16:32:05 -04:00
2022-09-23 06:45:40 +02:00
objects = RestrictedQuerySet.as_manager()
2017-09-21 16:32:05 -04:00
class Meta:
ordering = ['-created']
2017-09-21 16:32:05 -04:00
def __str__(self):
return str(self.job_id)
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
queue = django_rq.get_queue("default")
job = queue.fetch_job(str(self.job_id))
if job:
job.cancel()
2022-09-23 06:45:40 +02:00
def get_absolute_url(self):
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
2022-09-23 06:45:40 +02:00
@property
def duration(self):
if not self.completed:
return None
duration = self.completed - self.created
minutes, seconds = divmod(duration.total_seconds(), 60)
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
2022-11-15 14:38:58 -05:00
def start(self):
"""
Record the job's start time and update its status to "running."
"""
if self.started is None:
self.started = timezone.now()
self.status = JobResultStatusChoices.STATUS_RUNNING
JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status)
2020-07-03 11:55:04 -04:00
def set_status(self, status):
"""
2022-11-15 14:38:58 -05:00
Helper method to change the status of the job result. If the target status is terminal, the completion
time is also set.
2020-07-03 11:55:04 -04:00
"""
self.status = status
if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
self.completed = timezone.now()
@classmethod
2022-10-21 10:30:59 +02:00
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
"""
Create a JobResult instance and enqueue a job using the given callable
func: The callable object to be enqueued for execution
name: Name for the JobResult instance
obj_type: ContentType to link to the JobResult instance obj_type
user: User object to link to the JobResult instance
2022-10-21 10:30:59 +02:00
schedule_at: Schedule the job to be executed at the passed date and time
args: additional args passed to the callable
kwargs: additional kargs passed to the callable
"""
2022-09-23 06:45:40 +02:00
job_result: JobResult = cls.objects.create(
name=name,
obj_type=obj_type,
user=user,
job_id=uuid.uuid4()
)
2022-04-04 18:13:13 +02:00
2022-12-08 09:58:52 -05:00
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
2022-10-21 10:30:59 +02:00
if schedule_at:
2022-09-23 06:45:40 +02:00
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
job_result.scheduled = schedule_at
2022-09-23 06:45:40 +02:00
job_result.save()
2022-09-18 15:06:28 +02:00
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
else:
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
return job_result
2021-10-25 14:42:20 -04:00
class ConfigRevision(models.Model):
"""
An atomic revision of NetBox's configuration.
"""
created = models.DateTimeField(
auto_now_add=True
)
comment = models.CharField(
max_length=200,
blank=True
)
data = models.JSONField(
blank=True,
null=True,
verbose_name='Configuration data'
)
def __str__(self):
return f'Config revision #{self.pk} ({self.created})'
def __getattr__(self, item):
if item in self.data:
return self.data[item]
return super().__getattribute__(item)
def activate(self):
"""
Cache the configuration data.
"""
cache.set('config', self.data, None)
cache.set('config_version', self.pk, None)
2021-10-25 14:42:20 -04:00
@admin.display(boolean=True)
def is_active(self):
return cache.get('config_version') == self.pk
#
# Custom scripts & reports
#
2022-01-19 15:16:10 -05:00
class Script(JobResultsMixin, models.Model):
2021-10-25 14:42:20 -04:00
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
class Meta:
managed = False
#
# Reports
#
2022-01-19 15:16:10 -05:00
class Report(JobResultsMixin, models.Model):
2021-10-25 14:42:20 -04:00
"""
Dummy model used to generate permissions for reports. Does not exist in the database.
"""
class Meta:
managed = False