feat: Ordnerstruktur, Medienupload und erweiterten Editor ergänzen
- NetBox-4.6-Verknüpfungsformular korrigieren - hierarchische Dokumentationsordner hinzufügen - breite Editoransicht und Fokusmodus implementieren - sicheren direkten Bild-Upload integrieren - Ordner über Suche und REST-API bereitstellen
This commit is contained in:
@@ -6,6 +6,9 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
|
|||||||
|
|
||||||
- Dokumentationen direkt in NetBox schreiben und als sicher bereinigten Rich Text anzeigen
|
- Dokumentationen direkt in NetBox schreiben und als sicher bereinigten Rich Text anzeigen
|
||||||
- Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen
|
- Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen
|
||||||
|
- Hierarchische Ordner und Unterordner für eine BookStack-ähnliche Struktur
|
||||||
|
- Breite Editoransicht und Fokusmodus innerhalb des NetBox-Layouts
|
||||||
|
- Bilder direkt vom eigenen Gerät in die Dokumentation hochladen
|
||||||
- Eine Dokumentation mehreren Objekten zuordnen und umgekehrt
|
- Eine Dokumentation mehreren Objekten zuordnen und umgekehrt
|
||||||
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
|
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
|
||||||
- DOCX, XLSX/XLSM, textbasierte PDF-, Markdown- und Textdateien importieren
|
- DOCX, XLSX/XLSM, textbasierte PDF-, Markdown- und Textdateien importieren
|
||||||
@@ -15,7 +18,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
|
|||||||
|
|
||||||
## Kompatibilität
|
## Kompatibilität
|
||||||
|
|
||||||
Die Version `0.2.2` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden.
|
Die Version `0.3.0` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -119,6 +122,7 @@ Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
|
|||||||
|
|
||||||
- `/api/plugins/documentation/documents/`
|
- `/api/plugins/documentation/documents/`
|
||||||
- `/api/plugins/documentation/assignments/`
|
- `/api/plugins/documentation/assignments/`
|
||||||
|
- `/api/plugins/documentation/folders/`
|
||||||
|
|
||||||
## Entwicklung und Tests
|
## Entwicklung und Tests
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
|||||||
name = "netbox_documentation"
|
name = "netbox_documentation"
|
||||||
verbose_name = "Dokumentation"
|
verbose_name = "Dokumentation"
|
||||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
author = "NetBox Documentation Contributors"
|
author = "NetBox Documentation Contributors"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
min_version = "4.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from netbox.api.serializers import NetBoxModelSerializer
|
from netbox.api.serializers import NetBoxModelSerializer
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from ..models import Document, DocumentAssignment
|
from ..models import Document, DocumentAssignment, DocumentCategory
|
||||||
|
|
||||||
|
|
||||||
class DocumentSerializer(NetBoxModelSerializer):
|
class DocumentSerializer(NetBoxModelSerializer):
|
||||||
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Document
|
model = Document
|
||||||
fields = ("id", "url", "display", "title", "slug", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated")
|
fields = ("id", "url", "display", "title", "slug", "category", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated")
|
||||||
|
|
||||||
|
|
||||||
class DocumentAssignmentSerializer(NetBoxModelSerializer):
|
class DocumentAssignmentSerializer(NetBoxModelSerializer):
|
||||||
@@ -17,3 +17,11 @@ class DocumentAssignmentSerializer(NetBoxModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DocumentAssignment
|
model = DocumentAssignment
|
||||||
fields = ("id", "url", "display", "document", "assigned_object_type", "assigned_object_id", "note", "tags", "created", "last_updated")
|
fields = ("id", "url", "display", "document", "assigned_object_type", "assigned_object_id", "note", "tags", "created", "last_updated")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategorySerializer(NetBoxModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_documentation-api:documentcategory-detail")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DocumentCategory
|
||||||
|
fields = ("id", "url", "display", "name", "slug", "parent", "description", "tags", "created", "last_updated")
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ from . import views
|
|||||||
router = NetBoxRouter()
|
router = NetBoxRouter()
|
||||||
router.register("documents", views.DocumentViewSet)
|
router.register("documents", views.DocumentViewSet)
|
||||||
router.register("assignments", views.DocumentAssignmentViewSet)
|
router.register("assignments", views.DocumentAssignmentViewSet)
|
||||||
|
router.register("folders", views.DocumentCategoryViewSet)
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
from ..filtersets import DocumentFilterSet, AssignmentFilterSet
|
from ..filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
||||||
from ..models import Document, DocumentAssignment
|
from ..models import Document, DocumentAssignment, DocumentCategory
|
||||||
from .serializers import DocumentSerializer, DocumentAssignmentSerializer
|
from .serializers import DocumentSerializer, DocumentAssignmentSerializer, DocumentCategorySerializer
|
||||||
|
|
||||||
|
|
||||||
class DocumentViewSet(NetBoxModelViewSet):
|
class DocumentViewSet(NetBoxModelViewSet):
|
||||||
@@ -15,3 +15,8 @@ class DocumentAssignmentViewSet(NetBoxModelViewSet):
|
|||||||
serializer_class = DocumentAssignmentSerializer
|
serializer_class = DocumentAssignmentSerializer
|
||||||
filterset_class = AssignmentFilterSet
|
filterset_class = AssignmentFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryViewSet(NetBoxModelViewSet):
|
||||||
|
queryset = DocumentCategory.objects.all()
|
||||||
|
serializer_class = DocumentCategorySerializer
|
||||||
|
filterset_class = DocumentCategoryFilterSet
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from netbox.filtersets import NetBoxModelFilterSet
|
from netbox.filtersets import NetBoxModelFilterSet
|
||||||
from .models import Document, DocumentAssignment
|
from .models import Document, DocumentAssignment, DocumentCategory
|
||||||
|
|
||||||
|
|
||||||
class DocumentFilterSet(NetBoxModelFilterSet):
|
class DocumentFilterSet(NetBoxModelFilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Document
|
model = Document
|
||||||
fields = ("id", "title", "slug", "is_published")
|
fields = ("id", "title", "slug", "category_id", "is_published")
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -17,3 +17,11 @@ class AssignmentFilterSet(NetBoxModelFilterSet):
|
|||||||
model = DocumentAssignment
|
model = DocumentAssignment
|
||||||
fields = ("id", "document_id", "assigned_object_type", "assigned_object_id")
|
fields = ("id", "document_id", "assigned_object_type", "assigned_object_id")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryFilterSet(NetBoxModelFilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = DocumentCategory
|
||||||
|
fields = ("id", "name", "slug", "parent_id")
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
return queryset.filter(name__icontains=value)
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from utilities.forms.fields import SlugField
|
from utilities.forms import get_field_value
|
||||||
|
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelChoiceField, SlugField
|
||||||
|
from utilities.forms.rendering import FieldSet
|
||||||
|
from utilities.forms.widgets import HTMXSelect
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from .models import Document, DocumentAssignment
|
from dcim.models import Device
|
||||||
|
from .models import Document, DocumentAssignment, DocumentCategory
|
||||||
|
|
||||||
|
|
||||||
def allowed_content_types():
|
def allowed_content_types():
|
||||||
@@ -24,10 +28,16 @@ class DocumentForm(NetBoxModelForm):
|
|||||||
"rows": 32, "class": "rich-text-editor", "data-rich-text-editor": "true"
|
"rows": 32, "class": "rich-text-editor", "data-rich-text-editor": "true"
|
||||||
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
|
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
|
||||||
body_format = forms.CharField(widget=forms.HiddenInput(), initial="html")
|
body_format = forms.CharField(widget=forms.HiddenInput(), initial="html")
|
||||||
|
category = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Ordner")
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet("title", "slug", "category", "summary", "is_published", "tags", name="Dokumentation"),
|
||||||
|
FieldSet("body", "body_format", name="Inhalt"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Document
|
model = Document
|
||||||
fields = ("title", "slug", "summary", "body", "body_format", "is_published", "tags")
|
fields = ("title", "slug", "category", "summary", "body", "body_format", "is_published", "tags")
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -40,33 +50,55 @@ class DocumentForm(NetBoxModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class AssignmentForm(NetBoxModelForm):
|
class AssignmentForm(NetBoxModelForm):
|
||||||
assigned_object_type = forms.ModelChoiceField(queryset=ContentType.objects.none(), label="Objekttyp")
|
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
|
||||||
assigned_object_id = forms.TypedChoiceField(coerce=int, label="NetBox-Objekt")
|
assigned_object = DynamicModelChoiceField(
|
||||||
|
queryset=Device.objects.none(), label="NetBox-Objekt", required=True, disabled=True, selector=True
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (FieldSet("document", "assigned_object_type", "assigned_object", "note", "tags", name="Dokumentation zuordnen"),)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DocumentAssignment
|
model = DocumentAssignment
|
||||||
fields = ("document", "assigned_object_type", "assigned_object_id", "note", "tags")
|
fields = ("document", "assigned_object_type", "note", "tags")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
instance = kwargs.get("instance")
|
||||||
|
initial = kwargs.setdefault("initial", {})
|
||||||
|
if instance and instance.pk and instance.assigned_object:
|
||||||
|
initial["assigned_object"] = instance.assigned_object
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["assigned_object_type"].queryset = allowed_content_types()
|
||||||
|
ct_id = get_field_value(self, "assigned_object_type")
|
||||||
|
if ct_id:
|
||||||
|
content_type = self.fields["assigned_object_type"].queryset.filter(pk=ct_id).first()
|
||||||
|
else:
|
||||||
|
content_type = None
|
||||||
|
if content_type and content_type.model_class():
|
||||||
|
model = content_type.model_class()
|
||||||
|
self.fields["assigned_object"].queryset = model.objects.all()
|
||||||
|
self.fields["assigned_object"].widget.attrs["selector"] = model._meta.label_lower
|
||||||
|
self.fields["assigned_object"].disabled = False
|
||||||
|
self.fields["assigned_object"].label = model._meta.verbose_name.title()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
self.instance.assigned_object = self.cleaned_data.get("assigned_object")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryForm(NetBoxModelForm):
|
||||||
|
slug = SlugField(slug_source="name")
|
||||||
|
parent = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Übergeordneter Ordner")
|
||||||
|
|
||||||
|
fieldsets = (FieldSet("name", "slug", "parent", "description", "tags", name="Ordner"),)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DocumentCategory
|
||||||
|
fields = ("name", "slug", "parent", "description", "tags")
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["assigned_object_type"].queryset = allowed_content_types()
|
if self.instance.pk:
|
||||||
content_type = None
|
self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk)
|
||||||
ct_id = (self.data.get("assigned_object_type") or self.initial.get("assigned_object_type")
|
|
||||||
or getattr(self.instance, "assigned_object_type_id", None))
|
|
||||||
if ct_id:
|
|
||||||
content_type = ContentType.objects.filter(pk=ct_id).first()
|
|
||||||
if content_type:
|
|
||||||
model = content_type.model_class()
|
|
||||||
objects = model.objects.all().order_by("pk")
|
|
||||||
self.fields["assigned_object_id"].choices = [(obj.pk, str(obj)) for obj in objects]
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned = super().clean()
|
|
||||||
object_id = cleaned.get("assigned_object_id")
|
|
||||||
ct = cleaned.get("assigned_object_type")
|
|
||||||
if object_id and ct and not ct.model_class().objects.filter(pk=object_id).exists():
|
|
||||||
self.add_error("assigned_object_id", "Das Objekt existiert für diesen Objekttyp nicht.")
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
class ImportForm(forms.Form):
|
class ImportForm(forms.Form):
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import netbox.models.features
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("extras", "0001_squashed"),
|
||||||
|
("netbox_documentation", "0002_document_body_format"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DocumentCategory",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
("created", models.DateTimeField(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=netbox.models.features.CustomFieldJSONEncoder)),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("slug", models.SlugField(max_length=100)),
|
||||||
|
("description", models.CharField(blank=True, max_length=500)),
|
||||||
|
("parent", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="children", to="netbox_documentation.documentcategory")),
|
||||||
|
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_documentcategory_items", to="extras.tag")),
|
||||||
|
],
|
||||||
|
options={"verbose_name_plural": "document categories", "ordering": ("name",)},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="documentcategory",
|
||||||
|
constraint=models.UniqueConstraint(fields=("parent", "slug"), name="netbox_documentation_documentcategory_unique_parent_slug"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="category",
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="documents", to="netbox_documentation.documentcategory"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,6 +15,7 @@ class Document(NetBoxModel):
|
|||||||
body_format = models.CharField(max_length=10, choices=BODY_FORMAT_CHOICES, default="html")
|
body_format = models.CharField(max_length=10, choices=BODY_FORMAT_CHOICES, default="html")
|
||||||
summary = models.CharField(max_length=500, blank=True)
|
summary = models.CharField(max_length=500, blank=True)
|
||||||
is_published = models.BooleanField(default=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:
|
class Meta:
|
||||||
ordering = ("title",)
|
ordering = ("title",)
|
||||||
@@ -56,6 +57,42 @@ class Document(NetBoxModel):
|
|||||||
return bleach.clean(value, tags=tags, attributes=attributes, protocols=("http", "https", "mailto"), css_sanitizer=css)
|
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):
|
class DocumentAssignment(NetBoxModel):
|
||||||
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
|
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
|
||||||
assigned_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
assigned_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ menu = PluginMenu(
|
|||||||
PluginMenuItem(link="plugins:netbox_documentation:documentassignment_list", link_text="Zuordnungen", buttons=(
|
PluginMenuItem(link="plugins:netbox_documentation:documentassignment_list", link_text="Zuordnungen", buttons=(
|
||||||
PluginMenuButton(link="plugins:netbox_documentation:documentassignment_add", title="Zuordnen", icon_class="mdi mdi-link-plus", color=ButtonColorChoices.GREEN),
|
PluginMenuButton(link="plugins:netbox_documentation:documentassignment_add", title="Zuordnen", icon_class="mdi mdi-link-plus", color=ButtonColorChoices.GREEN),
|
||||||
)),
|
)),
|
||||||
|
PluginMenuItem(link="plugins:netbox_documentation:documentcategory_list", link_text="Ordner & Kategorien", buttons=(
|
||||||
|
PluginMenuButton(link="plugins:netbox_documentation:documentcategory_add", title="Ordner erstellen", icon_class="mdi mdi-folder-plus", color=ButtonColorChoices.GREEN),
|
||||||
|
)),
|
||||||
)),),
|
)),),
|
||||||
icon_class="mdi mdi-book-open-page-variant",
|
icon_class="mdi mdi-book-open-page-variant",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from netbox.search import SearchIndex, register_search
|
from netbox.search import SearchIndex, register_search
|
||||||
from .models import Document
|
from .models import Document, DocumentCategory
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@@ -8,3 +8,9 @@ class DocumentIndex(SearchIndex):
|
|||||||
fields = (("title", 100), ("summary", 80), ("body", 50))
|
fields = (("title", 100), ("summary", 80), ("body", 50))
|
||||||
display_attrs = ("summary",)
|
display_attrs = ("summary",)
|
||||||
|
|
||||||
|
|
||||||
|
@register_search
|
||||||
|
class DocumentCategoryIndex(SearchIndex):
|
||||||
|
model = DocumentCategory
|
||||||
|
fields = (("name", 100), ("description", 60))
|
||||||
|
display_attrs = ("description",)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from .models import Document, DocumentAssignment
|
from .models import Document, DocumentAssignment, DocumentCategory
|
||||||
|
|
||||||
|
|
||||||
class DocumentTable(NetBoxTable):
|
class DocumentTable(NetBoxTable):
|
||||||
@@ -10,7 +10,17 @@ class DocumentTable(NetBoxTable):
|
|||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Document
|
model = Document
|
||||||
fields = ("pk", "title", "summary", "is_published", "assignments", "last_updated", "actions")
|
fields = ("pk", "title", "category", "summary", "is_published", "assignments", "last_updated", "actions")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryTable(NetBoxTable):
|
||||||
|
name = tables.Column(linkify=True, verbose_name="Ordner")
|
||||||
|
document_count = tables.Column(accessor="documents.count", verbose_name="Dokumente", orderable=False)
|
||||||
|
actions = columns.ActionsColumn(actions=("edit", "delete"))
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = DocumentCategory
|
||||||
|
fields = ("pk", "name", "parent", "description", "document_count", "actions")
|
||||||
|
|
||||||
|
|
||||||
class AssignmentTable(NetBoxTable):
|
class AssignmentTable(NetBoxTable):
|
||||||
@@ -21,4 +31,3 @@ class AssignmentTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = DocumentAssignment
|
model = DocumentAssignment
|
||||||
fields = ("pk", "document", "assigned_object_type", "assigned_object", "note", "actions")
|
fields = ("pk", "document", "assigned_object_type", "assigned_object", "note", "actions")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<div class="card"><div class="card-body rendered-markdown">{{ object.rendered_body|safe }}</div></div>
|
<div class="card"><div class="card-body rendered-markdown">{{ object.rendered_body|safe }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-3">
|
<div class="col col-md-3">
|
||||||
|
{% if object.category %}<div class="card mb-3"><h5 class="card-header">Ordner</h5><a class="list-group-item list-group-item-action" href="{{ object.category.get_absolute_url }}"><i class="mdi mdi-folder"></i> {{ object.category }}</a></div>{% endif %}
|
||||||
<div class="card"><h5 class="card-header">Zuordnungen</h5><div class="list-group list-group-flush">
|
<div class="card"><h5 class="card-header">Zuordnungen</h5><div class="list-group list-group-flush">
|
||||||
{% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %}
|
{% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %}
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
{% extends 'generic/object_edit.html' %}
|
{% extends 'generic/object_edit.html' %}
|
||||||
{% block javascript %}
|
{% block javascript %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
body.documentation-editor-page .container-xl { max-width: none !important; }
|
||||||
|
body.documentation-editor-focus #form_fields .field-group:not(.documentation-content-group) { display: none; }
|
||||||
|
body.documentation-editor-focus #form_fields .documentation-content-group { margin-bottom: 0 !important; }
|
||||||
|
</style>
|
||||||
<script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script>
|
<script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.body.classList.add('documentation-editor-page');
|
||||||
|
const bodyField = document.querySelector('#id_body');
|
||||||
|
if (bodyField) {
|
||||||
|
const contentGroup = bodyField.closest('.field-group');
|
||||||
|
if (contentGroup) contentGroup.classList.add('documentation-content-group');
|
||||||
|
}
|
||||||
const editorAssetBase = "{% url 'plugins:netbox_documentation:editor_asset' asset_path='_' %}".replace(/_$/, '');
|
const editorAssetBase = "{% url 'plugins:netbox_documentation:editor_asset' asset_path='_' %}".replace(/_$/, '');
|
||||||
if (typeof tinymce === 'undefined') {
|
if (typeof tinymce === 'undefined') {
|
||||||
const editor = document.querySelector('[data-rich-text-editor]');
|
const editor = document.querySelector('[data-rich-text-editor]');
|
||||||
@@ -25,7 +36,7 @@
|
|||||||
plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount',
|
plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount',
|
||||||
toolbar: [
|
toolbar: [
|
||||||
'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor',
|
'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor',
|
||||||
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image media | removeformat code fullscreen'
|
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image media | removeformat code netboxfocus fullscreen'
|
||||||
],
|
],
|
||||||
toolbar_mode: 'sliding',
|
toolbar_mode: 'sliding',
|
||||||
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
|
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
|
||||||
@@ -38,7 +49,43 @@
|
|||||||
branding: false,
|
branding: false,
|
||||||
convert_unsafe_embeds: true,
|
convert_unsafe_embeds: true,
|
||||||
relative_urls: false
|
relative_urls: false
|
||||||
|
{% if object.pk %},
|
||||||
|
images_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
|
automatic_uploads: true,
|
||||||
|
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('file', blobInfo.blob(), blobInfo.filename());
|
||||||
|
fetch("{% url 'plugins:netbox_documentation:document_media_upload' pk=object.pk %}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-CSRFToken': document.querySelector('input[name=csrfmiddlewaretoken]').value},
|
||||||
|
body: data,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(async response => {
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.error || 'Upload fehlgeschlagen');
|
||||||
|
resolve(payload.location);
|
||||||
|
}).catch(error => reject(error.message));
|
||||||
|
})
|
||||||
|
{% endif %},
|
||||||
|
setup: (editor) => {
|
||||||
|
editor.ui.registry.addToggleButton('netboxfocus', {
|
||||||
|
icon: 'expand',
|
||||||
|
tooltip: 'Fokusmodus im NetBox-Fenster',
|
||||||
|
onAction: api => {
|
||||||
|
const active = document.body.classList.toggle('documentation-editor-focus');
|
||||||
|
const container = editor.getContainer();
|
||||||
|
container.style.height = active ? 'calc(100vh - 190px)' : '650px';
|
||||||
|
api.setActive(active);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock javascript %}
|
{% endblock javascript %}
|
||||||
|
|
||||||
|
{% block pre_form_fields %}
|
||||||
|
{% if not object.pk %}
|
||||||
|
<div class="alert alert-info">Speichere die neue Dokumentation einmal. Danach können Bilder direkt über die Editor-Werkzeugleiste hochgeladen werden.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock pre_form_fields %}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Unterordner</h5>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for child in object.children.all %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ child.get_absolute_url }}"><i class="mdi mdi-folder"></i> {{ child.name }}</a>
|
||||||
|
{% empty %}<div class="list-group-item text-muted">Keine Unterordner</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Dokumentationen</h5>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for document in object.documents.all %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ document.get_absolute_url }}"><strong>{{ document.title }}</strong>{% if document.summary %}<br><small>{{ document.summary }}</small>{% endif %}</a>
|
||||||
|
{% empty %}<div class="list-group-item text-muted">Keine Dokumentationen in diesem Ordner</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -7,5 +7,5 @@
|
|||||||
</a>
|
</a>
|
||||||
{% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %}
|
{% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if perms.netbox_documentation.add_documentassignment %}<div class="card-footer"><a href="{% url 'plugins:netbox_documentation:documentassignment_add' %}?assigned_object_type={{ content_type.pk }}&assigned_object_id={{ object.pk }}" class="btn btn-sm btn-primary"><i class="mdi mdi-link-plus"></i> Zuordnen</a></div>{% endif %}
|
{% if perms.netbox_documentation.add_documentassignment %}<div class="card-footer"><a href="{% url 'plugins:netbox_documentation:documentassignment_add' %}?assigned_object_type={{ content_type.pk }}&assigned_object={{ object.pk }}" class="btn btn-sm btn-primary"><i class="mdi mdi-link-plus"></i> Zuordnen</a></div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ urlpatterns = (
|
|||||||
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
|
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
|
||||||
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
|
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
|
||||||
path("documents/<int:pk>/edit/", views.DocumentEditView.as_view(), name="document_edit"),
|
path("documents/<int:pk>/edit/", views.DocumentEditView.as_view(), name="document_edit"),
|
||||||
|
path("documents/<int:pk>/media-upload/", views.DocumentMediaUploadView.as_view(), name="document_media_upload"),
|
||||||
path("documents/<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="document_delete"),
|
path("documents/<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="document_delete"),
|
||||||
path("documents/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}),
|
path("documents/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}),
|
||||||
path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"),
|
path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"),
|
||||||
path("assignments/add/", views.AssignmentEditView.as_view(), name="documentassignment_add"),
|
path("assignments/add/", views.AssignmentEditView.as_view(), name="documentassignment_add"),
|
||||||
path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"),
|
path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"),
|
||||||
path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"),
|
path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"),
|
||||||
|
path("folders/", views.DocumentCategoryListView.as_view(), name="documentcategory_list"),
|
||||||
|
path("folders/add/", views.DocumentCategoryEditView.as_view(), name="documentcategory_add"),
|
||||||
|
path("folders/<int:pk>/", views.DocumentCategoryView.as_view(), name="documentcategory"),
|
||||||
|
path("folders/<int:pk>/edit/", views.DocumentCategoryEditView.as_view(), name="documentcategory_edit"),
|
||||||
|
path("folders/<int:pk>/delete/", views.DocumentCategoryDeleteView.as_view(), name="documentcategory_delete"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404, JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from .filtersets import DocumentFilterSet, AssignmentFilterSet
|
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
||||||
from .forms import DocumentForm, AssignmentForm, ImportForm
|
from .forms import DocumentForm, AssignmentForm, ImportForm, DocumentCategoryForm
|
||||||
from .importers import ImportFailure, import_document
|
from .importers import ImportFailure, import_document
|
||||||
from .models import Document, DocumentAssignment, DocumentAttachment
|
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
||||||
from .tables import DocumentTable, AssignmentTable
|
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
|
||||||
|
|
||||||
|
|
||||||
class EditorAssetView(View):
|
class EditorAssetView(View):
|
||||||
@@ -52,6 +52,25 @@ class DocumentDeleteView(generic.ObjectDeleteView):
|
|||||||
queryset = Document.objects.all()
|
queryset = Document.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryListView(generic.ObjectListView):
|
||||||
|
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents")
|
||||||
|
table = DocumentCategoryTable
|
||||||
|
filterset = DocumentCategoryFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryView(generic.ObjectView):
|
||||||
|
queryset = DocumentCategory.objects.prefetch_related("children", "documents")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryEditView(generic.ObjectEditView):
|
||||||
|
queryset = DocumentCategory.objects.all()
|
||||||
|
form = DocumentCategoryForm
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = DocumentCategory.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class AssignmentListView(generic.ObjectListView):
|
class AssignmentListView(generic.ObjectListView):
|
||||||
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
|
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
|
||||||
table = AssignmentTable
|
table = AssignmentTable
|
||||||
@@ -113,3 +132,31 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
messages.warning(request, warning)
|
messages.warning(request, warning)
|
||||||
messages.success(request, f"{upload.name} wurde importiert.")
|
messages.success(request, f"{upload.name} wurde importiert.")
|
||||||
return redirect(document)
|
return redirect(document)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentMediaUploadView(PermissionRequiredMixin, View):
|
||||||
|
permission_required = "netbox_documentation.change_document"
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
document = get_object_or_404(Document.objects.restrict(request.user, "change"), pk=pk)
|
||||||
|
upload = request.FILES.get("file")
|
||||||
|
if not upload:
|
||||||
|
return JsonResponse({"error": "Keine Datei empfangen."}, status=400)
|
||||||
|
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
|
||||||
|
if upload.size > limit * 1024 * 1024:
|
||||||
|
return JsonResponse({"error": f"Die Datei ist größer als {limit} MB."}, status=400)
|
||||||
|
allowed = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
|
if upload.content_type not in allowed:
|
||||||
|
return JsonResponse({"error": "Im Editor sind JPEG, PNG, GIF und WebP erlaubt."}, status=400)
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
image = Image.open(upload)
|
||||||
|
image.verify()
|
||||||
|
upload.seek(0)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"error": "Die hochgeladene Datei ist kein gültiges Bild."}, status=400)
|
||||||
|
attachment = DocumentAttachment.objects.create(
|
||||||
|
document=document, file=upload, original_name=upload.name,
|
||||||
|
content_type=upload.content_type or "", size=upload.size,
|
||||||
|
)
|
||||||
|
return JsonResponse({"location": attachment.file.url})
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"bleach[css]>=6.2,<7",
|
"bleach[css]>=6.2,<7",
|
||||||
"markdown>=3.7,<4",
|
"markdown>=3.7,<4",
|
||||||
"django-tinymce>=5,<6",
|
"django-tinymce>=5,<6",
|
||||||
|
"pillow>=10,<13",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user