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
|
||||
- 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
|
||||
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
@@ -119,6 +122,7 @@ Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
|
||||
|
||||
- `/api/plugins/documentation/documents/`
|
||||
- `/api/plugins/documentation/assignments/`
|
||||
- `/api/plugins/documentation/folders/`
|
||||
|
||||
## Entwicklung und Tests
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
||||
name = "netbox_documentation"
|
||||
verbose_name = "Dokumentation"
|
||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||
version = "0.2.2"
|
||||
version = "0.3.0"
|
||||
author = "NetBox Documentation Contributors"
|
||||
base_url = "documentation"
|
||||
min_version = "4.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from rest_framework import serializers
|
||||
from ..models import Document, DocumentAssignment
|
||||
from ..models import Document, DocumentAssignment, DocumentCategory
|
||||
|
||||
|
||||
class DocumentSerializer(NetBoxModelSerializer):
|
||||
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
@@ -17,3 +17,11 @@ class DocumentAssignmentSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = DocumentAssignment
|
||||
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.register("documents", views.DocumentViewSet)
|
||||
router.register("assignments", views.DocumentAssignmentViewSet)
|
||||
router.register("folders", views.DocumentCategoryViewSet)
|
||||
urlpatterns = router.urls
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from ..filtersets import DocumentFilterSet, AssignmentFilterSet
|
||||
from ..models import Document, DocumentAssignment
|
||||
from .serializers import DocumentSerializer, DocumentAssignmentSerializer
|
||||
from ..filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
||||
from ..models import Document, DocumentAssignment, DocumentCategory
|
||||
from .serializers import DocumentSerializer, DocumentAssignmentSerializer, DocumentCategorySerializer
|
||||
|
||||
|
||||
class DocumentViewSet(NetBoxModelViewSet):
|
||||
@@ -15,3 +15,8 @@ class DocumentAssignmentViewSet(NetBoxModelViewSet):
|
||||
serializer_class = DocumentAssignmentSerializer
|
||||
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 .models import Document, DocumentAssignment
|
||||
from .models import Document, DocumentAssignment, DocumentCategory
|
||||
|
||||
|
||||
class DocumentFilterSet(NetBoxModelFilterSet):
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ("id", "title", "slug", "is_published")
|
||||
fields = ("id", "title", "slug", "category_id", "is_published")
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
from django.db.models import Q
|
||||
@@ -17,3 +17,11 @@ class AssignmentFilterSet(NetBoxModelFilterSet):
|
||||
model = DocumentAssignment
|
||||
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.contrib.contenttypes.models import ContentType
|
||||
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 .models import Document, DocumentAssignment
|
||||
from dcim.models import Device
|
||||
from .models import Document, DocumentAssignment, DocumentCategory
|
||||
|
||||
|
||||
def allowed_content_types():
|
||||
@@ -24,10 +28,16 @@ class DocumentForm(NetBoxModelForm):
|
||||
"rows": 32, "class": "rich-text-editor", "data-rich-text-editor": "true"
|
||||
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
|
||||
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:
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -40,33 +50,55 @@ class DocumentForm(NetBoxModelForm):
|
||||
|
||||
|
||||
class AssignmentForm(NetBoxModelForm):
|
||||
assigned_object_type = forms.ModelChoiceField(queryset=ContentType.objects.none(), label="Objekttyp")
|
||||
assigned_object_id = forms.TypedChoiceField(coerce=int, label="NetBox-Objekt")
|
||||
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
|
||||
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:
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["assigned_object_type"].queryset = allowed_content_types()
|
||||
content_type = None
|
||||
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
|
||||
if self.instance.pk:
|
||||
self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk)
|
||||
|
||||
|
||||
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")
|
||||
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",)
|
||||
@@ -56,6 +57,42 @@ class Document(NetBoxModel):
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,9 @@ menu = PluginMenu(
|
||||
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),
|
||||
)),
|
||||
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",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from netbox.search import SearchIndex, register_search
|
||||
from .models import Document
|
||||
from .models import Document, DocumentCategory
|
||||
|
||||
|
||||
@register_search
|
||||
@@ -8,3 +8,9 @@ class DocumentIndex(SearchIndex):
|
||||
fields = (("title", 100), ("summary", 80), ("body", 50))
|
||||
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
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from .models import Document, DocumentAssignment
|
||||
from .models import Document, DocumentAssignment, DocumentCategory
|
||||
|
||||
|
||||
class DocumentTable(NetBoxTable):
|
||||
@@ -10,7 +10,17 @@ class DocumentTable(NetBoxTable):
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
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):
|
||||
@@ -21,4 +31,3 @@ class AssignmentTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = DocumentAssignment
|
||||
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>
|
||||
<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">
|
||||
{% 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>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
{% block javascript %}
|
||||
{{ 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>
|
||||
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(/_$/, '');
|
||||
if (typeof tinymce === 'undefined') {
|
||||
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',
|
||||
toolbar: [
|
||||
'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',
|
||||
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
|
||||
@@ -38,7 +49,43 @@
|
||||
branding: false,
|
||||
convert_unsafe_embeds: true,
|
||||
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>
|
||||
{% 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>
|
||||
{% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %}
|
||||
</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>
|
||||
|
||||
@@ -9,10 +9,16 @@ urlpatterns = (
|
||||
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
|
||||
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>/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>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}),
|
||||
path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"),
|
||||
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>/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.auth.mixins import PermissionRequiredMixin
|
||||
from django.db import transaction
|
||||
from django.http import FileResponse, Http404
|
||||
from django.shortcuts import redirect, render
|
||||
from django.http import FileResponse, Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.text import slugify
|
||||
from django.views import View
|
||||
from netbox.views import generic
|
||||
from .filtersets import DocumentFilterSet, AssignmentFilterSet
|
||||
from .forms import DocumentForm, AssignmentForm, ImportForm
|
||||
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
||||
from .forms import DocumentForm, AssignmentForm, ImportForm, DocumentCategoryForm
|
||||
from .importers import ImportFailure, import_document
|
||||
from .models import Document, DocumentAssignment, DocumentAttachment
|
||||
from .tables import DocumentTable, AssignmentTable
|
||||
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
||||
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
|
||||
|
||||
|
||||
class EditorAssetView(View):
|
||||
@@ -52,6 +52,25 @@ class DocumentDeleteView(generic.ObjectDeleteView):
|
||||
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):
|
||||
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
|
||||
table = AssignmentTable
|
||||
@@ -113,3 +132,31 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
||||
messages.warning(request, warning)
|
||||
messages.success(request, f"{upload.name} wurde importiert.")
|
||||
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]
|
||||
name = "netbox-documentation"
|
||||
version = "0.2.2"
|
||||
version = "0.3.0"
|
||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"bleach[css]>=6.2,<7",
|
||||
"markdown>=3.7,<4",
|
||||
"django-tinymce>=5,<6",
|
||||
"pillow>=10,<13",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Reference in New Issue
Block a user