diff --git a/README.md b/README.md index f87b650..98759fe 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit - Tabellen-Presets, farbige Kopfzeilen und Zellhervorhebungen im WYSIWYG-Editor - Excel-Mehrblattimport: je Arbeitsblatt eine Dokumentation in einem gewählten Zielordner - Zweistufige Excel-Vorschau mit Blattauswahl und frei änderbaren Dokumenttiteln +- Benutzergebundene Bearbeitungssperren mit Heartbeat und zusätzlicher Versionskonfliktprüfung - Optionales Glätten von Excel-Tabellen in kompakte Feld-/Wert-Textblöcke - Eine Dokumentation mehreren Objekten zuordnen und umgekehrt - Unbegrenzt viele Objektzuordnungen pro Dokumentation; nur identische Doppelzuordnungen werden verhindert @@ -28,7 +29,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Kompatibilität -Die Version `0.7.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. +Die Version `0.8.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 @@ -77,6 +78,7 @@ PLUGINS_CONFIG = { "netbox_documentation": { "max_import_size_mb": 25, "max_archive_size_mb": 250, + "edit_lock_timeout_minutes": 5, "keep_imported_file": True, "allowed_object_types": [ "dcim.region", @@ -92,6 +94,12 @@ PLUGINS_CONFIG = { } ``` +### Gleichzeitiges Bearbeiten + +Beim Öffnen einer bestehenden Dokumentation im Editor wird eine benutzergebundene Bearbeitungssperre gesetzt und während der Arbeit regelmäßig verlängert. Andere Benutzer sehen den aktiven Bearbeiter und können erst nach Freigabe oder Ablauf der Sperre in den Editor wechseln. `edit_lock_timeout_minutes` bestimmt die Zeit ohne Heartbeat, nach der eine abgebrochene Sitzung automatisch übernommen werden darf. + +Zusätzlich wird beim Speichern die ursprünglich geöffnete Version mit dem aktuellen Datenbankstand verglichen. Wurde die Dokumentation zwischenzeitlich verändert, bricht das Plugin den Speichervorgang ab, zeigt den nicht gespeicherten Inhalt zum Kopieren an und überschreibt die neuere Fassung nicht. + TinyMCE wird als Python-Abhängigkeit des Plugins installiert und über einen lokalen, gecachten Plugin-Endpunkt bereitgestellt. `collectstatic` übernimmt die Dateien zusätzlich in NetBox. Der Editor benötigt deshalb weder Zugriff auf ein CDN noch einen API-Key. Das verwendete TinyMCE wird im GPL-Modus betrieben. Kann das Script nicht geladen werden, bleibt als Rückfall ein normales HTML-Textfeld verfügbar. Danach die Migrationen und statischen Dateien aktualisieren und NetBox neu starten: diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 58c5b99..e342765 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig): name = "netbox_documentation" verbose_name = "NetBox Dokumentation" description = "Wiki und Office-Dokumentation direkt in NetBox" - version = "0.7.0" + version = "0.8.0" author = "LKE" base_url = "documentation" min_version = "4.0.0" @@ -18,6 +18,7 @@ class DocumentationConfig(PluginConfig): ], "max_import_size_mb": 25, "max_archive_size_mb": 250, + "edit_lock_timeout_minutes": 5, "keep_imported_file": True, } diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 8d8ea7e..cd29b51 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -39,6 +39,8 @@ 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") + edit_lock_token = forms.CharField(required=False, widget=forms.HiddenInput()) + expected_version = forms.CharField(required=False, widget=forms.HiddenInput()) category = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Ordner") fieldsets = ( diff --git a/netbox_documentation/migrations/0005_documenteditlock.py b/netbox_documentation/migrations/0005_documenteditlock.py new file mode 100644 index 0000000..2fcba10 --- /dev/null +++ b/netbox_documentation/migrations/0005_documenteditlock.py @@ -0,0 +1,25 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("netbox_documentation", "0004_excelimportpreview"), + ] + + operations = [ + migrations.CreateModel( + name="DocumentEditLock", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("token", models.UUIDField(default=uuid.uuid4, editable=False)), + ("acquired", models.DateTimeField(auto_now_add=True)), + ("heartbeat", models.DateTimeField(auto_now=True)), + ("document", models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="edit_lock", to="netbox_documentation.document")), + ("user", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/netbox_documentation/models.py b/netbox_documentation/models.py index 64bd137..8e20023 100644 --- a/netbox_documentation/models.py +++ b/netbox_documentation/models.py @@ -169,3 +169,17 @@ class ExcelImportPreview(models.Model): def __str__(self): return self.original_name + + +class DocumentEditLock(models.Model): + """Short-lived advisory lock; optimistic version checks remain authoritative.""" + _netbox_private = True + + document = models.OneToOneField(Document, on_delete=models.SET_NULL, null=True, related_name="edit_lock") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + token = models.UUIDField(default=uuid.uuid4, editable=False) + acquired = models.DateTimeField(auto_now_add=True) + heartbeat = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.document} – {self.user}" diff --git a/netbox_documentation/templates/netbox_documentation/document_edit.html b/netbox_documentation/templates/netbox_documentation/document_edit.html index c4bf02c..dfebd8a 100644 --- a/netbox_documentation/templates/netbox_documentation/document_edit.html +++ b/netbox_documentation/templates/netbox_documentation/document_edit.html @@ -135,12 +135,48 @@ }); } }); + + {% if object.pk %} + const lockToken = document.getElementById('id_edit_lock_token'); + const editForm = document.querySelector('form.object-edit'); + const csrfToken = document.querySelector('input[name=csrfmiddlewaretoken]'); + const lockUrl = "{% url 'plugins:netbox_documentation:document_lock' pk=object.pk %}"; + let submitting = false; + const heartbeat = () => { + if (!lockToken || !lockToken.value || submitting) return; + const data = new URLSearchParams({token: lockToken.value, action: 'heartbeat'}); + fetch(lockUrl, { + method: 'POST', credentials: 'same-origin', body: data, + headers: {'X-CSRFToken': csrfToken.value, 'Content-Type': 'application/x-www-form-urlencoded'} + }).then(response => { + if (response.status === 409) { + const warning = document.createElement('div'); + warning.className = 'alert alert-danger position-fixed top-0 start-50 translate-middle-x mt-3'; + warning.style.zIndex = '2000'; + warning.textContent = 'Die Bearbeitungssperre ist abgelaufen oder wurde übernommen. Bitte Inhalt sichern und die Seite neu laden.'; + document.body.appendChild(warning); + } + }); + }; + const heartbeatTimer = window.setInterval(heartbeat, 60000); + if (editForm) editForm.addEventListener('submit', () => { submitting = true; window.clearInterval(heartbeatTimer); }); + window.addEventListener('beforeunload', () => { + if (submitting || !lockToken || !lockToken.value) return; + const data = new FormData(); + data.append('csrfmiddlewaretoken', csrfToken.value); + data.append('token', lockToken.value); + data.append('action', 'release'); + navigator.sendBeacon(lockUrl, data); + }); + {% endif %} }); {% endblock javascript %} {% block pre_form_fields %} - {% if not object.pk %} + {% if object.pk %} +
Die Dokumentation wurde seit dem Öffnen verändert. Deine Fassung wurde nicht gespeichert und die neuere Version wurde nicht überschrieben.
+Titel: {{ submitted_title }}
+ +Diesen Inhalt kannst du kopieren und nach Prüfung in die aktuelle Version übernehmen.
+Deine Bearbeitungssperre ist nicht mehr gültig. Der Inhalt wurde nicht überschrieben.
+ {% elif lock.user %} +{{ lock.user }} bearbeitet „{{ object.title }}“ momentan.
+ {% else %} +Diese Dokumentation besitzt momentan eine aktive Bearbeitungssperre.
+ {% endif %} +Ohne Heartbeat läuft eine Sperre nach {{ timeout }} Minuten automatisch ab.
+ +