Files
Netbox-Documentation/netbox_documentation/models.py
T

172 lines
7.1 KiB
Python

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.conf import settings
from django.urls import reverse
from pathlib import Path
import uuid
from netbox.models import NetBoxModel
BODY_FORMAT_CHOICES = (("html", "Rich Text"), ("markdown", "Markdown"))
class Document(NetBoxModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
body = models.TextField(blank=True, help_text="Markdown")
body_format = models.CharField(max_length=10, choices=BODY_FORMAT_CHOICES, default="html")
summary = models.CharField(max_length=500, blank=True)
is_published = models.BooleanField(default=True)
category = models.ForeignKey("DocumentCategory", on_delete=models.SET_NULL, null=True, blank=True, related_name="documents")
class Meta:
ordering = ("title",)
permissions = (("import_document", "Can import office documents"),)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("plugins:netbox_documentation:document", args=[self.pk])
def rendered_body(self):
"""Render Markdown/HTML and remove executable or unsafe markup."""
import bleach
from bleach.css_sanitizer import CSSSanitizer
from markdown import markdown
value = markdown(self.body, extensions=("extra", "sane_lists")) if self.body_format == "markdown" else self.body
tags = {
"a", "abbr", "blockquote", "br", "caption", "code", "col", "colgroup", "div",
"em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "li", "ol", "p",
"pre", "span", "strong", "sub", "sup", "table", "tbody", "td", "tfoot", "th",
"thead", "tr", "u", "ul",
}
attributes = {
"a": ("href", "title", "target", "rel"),
"img": ("src", "alt", "title", "width", "height"),
"table": ("class", "style"),
"th": ("colspan", "rowspan", "scope", "style"),
"td": ("colspan", "rowspan", "style"),
"col": ("span", "style"),
"*": ("class", "style"),
}
css = CSSSanitizer(allowed_css_properties={
"background", "background-color", "border", "border-color", "border-style", "border-width",
"color", "font-family", "font-size", "font-style", "font-weight", "height",
"border-collapse", "line-height", "list-style-type", "margin", "margin-bottom",
"margin-left", "margin-right", "margin-top", "padding", "padding-bottom",
"padding-left", "padding-right", "padding-top", "text-align", "text-decoration",
"text-indent", "vertical-align", "white-space", "width",
})
return bleach.clean(value, tags=tags, attributes=attributes, protocols=("http", "https", "mailto"), css_sanitizer=css)
class DocumentCategory(NetBoxModel):
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100)
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
description = models.CharField(max_length=500, blank=True)
class Meta:
ordering = ("name",)
constraints = [models.UniqueConstraint(fields=("parent", "slug"), name="%(app_label)s_%(class)s_unique_parent_slug")]
verbose_name_plural = "document categories"
def __str__(self):
names = [self.name]
parent = self.parent
seen = {self.pk}
while parent and parent.pk not in seen:
names.append(parent.name)
seen.add(parent.pk)
parent = parent.parent
return " / ".join(reversed(names))
def get_absolute_url(self):
return reverse("plugins:netbox_documentation:documentcategory", args=[self.pk])
def clean(self):
super().clean()
from django.core.exceptions import ValidationError
parent = self.parent
seen = {self.pk} if self.pk else set()
while parent:
if parent.pk in seen:
raise ValidationError({"parent": "Ein Ordner kann nicht sich selbst oder einem Unterordner untergeordnet werden."})
seen.add(parent.pk)
parent = parent.parent
class DocumentAssignment(NetBoxModel):
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
assigned_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
assigned_object_id = models.PositiveBigIntegerField()
assigned_object = GenericForeignKey("assigned_object_type", "assigned_object_id")
note = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ("document", "assigned_object_type", "assigned_object_id")
constraints = [models.UniqueConstraint(
fields=("document", "assigned_object_type", "assigned_object_id"),
name="%(app_label)s_%(class)s_unique_assignment",
)]
def __str__(self):
return f"{self.document}{self.assigned_object}"
def get_absolute_url(self):
# No separate detail page exists for an assignment. NetBox's generic
# edit view nevertheless needs a valid URL for its success message.
return reverse("plugins:netbox_documentation:documentassignment_list")
def attachment_upload_path(instance, filename):
return f"netbox_documentation/{instance.document_id}/{filename}"
class DocumentAttachment(NetBoxModel):
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="attachments")
file = models.FileField(upload_to=attachment_upload_path)
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=100, blank=True)
size = models.PositiveBigIntegerField(default=0)
class Meta:
ordering = ("original_name",)
def __str__(self):
return self.original_name
def get_absolute_url(self):
# Attachments are displayed on their parent document and intentionally
# have no standalone detail view. This is also used by NetBox's delete
# dependency collector when rendering the confirmation dialog.
return self.document.get_absolute_url()
def preview_upload_path(instance, filename):
return f"netbox_documentation/import-previews/{instance.pk}/{Path(filename).name}"
class ExcelImportPreview(models.Model):
"""Short-lived, user-bound state for the two-step Excel import workflow."""
_netbox_private = True
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
category = models.ForeignKey(DocumentCategory, on_delete=models.SET_NULL, null=True)
file = models.FileField(upload_to=preview_upload_path)
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=100, blank=True)
flatten = models.BooleanField(default=False)
sheet_metadata = models.JSONField(default=list)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created",)
def __str__(self):
return self.original_name