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") tenant_group = models.ForeignKey( "tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True, related_name="documentation_documents", ) tenant = models.ForeignKey( "tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True, related_name="documentation_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) tenant_group = models.ForeignKey( "tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True, related_name="documentation_categories", ) tenant = models.ForeignKey( "tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True, related_name="documentation_categories", ) 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