Revert "feat: Bearbeitungssperren und Versionskonfliktschutz ergänzen"

This reverts commit 601c7308ce.
This commit is contained in:
2026-07-23 13:44:08 +02:00
parent 601c7308ce
commit 58637e1200
11 changed files with 5 additions and 234 deletions
+1 -9
View File
@@ -17,7 +17,6 @@ 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
@@ -29,7 +28,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
## Kompatibilität
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.
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.
## Installation
@@ -78,7 +77,6 @@ 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",
@@ -94,12 +92,6 @@ 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:
+1 -2
View File
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
name = "netbox_documentation"
verbose_name = "NetBox Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox"
version = "0.8.0"
version = "0.7.0"
author = "LKE"
base_url = "documentation"
min_version = "4.0.0"
@@ -18,7 +18,6 @@ class DocumentationConfig(PluginConfig):
],
"max_import_size_mb": 25,
"max_archive_size_mb": 250,
"edit_lock_timeout_minutes": 5,
"keep_imported_file": True,
}
-2
View File
@@ -39,8 +39,6 @@ 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 = (
@@ -1,25 +0,0 @@
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)),
],
),
]
-14
View File
@@ -169,17 +169,3 @@ 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}"
@@ -135,48 +135,12 @@
});
}
});
{% 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 %}
});
</script>
{% endblock javascript %}
{% block pre_form_fields %}
{% if object.pk %}
<div class="alert alert-success py-2"><i class="mdi mdi-lock-check"></i> Diese Dokumentation ist während der Bearbeitung für dich reserviert.</div>
{% else %}
{% 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 %}
@@ -1,24 +0,0 @@
{% extends 'base/layout.html' %}
{% block title %}Bearbeitungskonflikt{% endblock %}
{% block content %}
<div class="alert alert-danger">
<h4><i class="mdi mdi-content-save-alert"></i> Neuere Version erkannt</h4>
<p>Die Dokumentation wurde seit dem Öffnen verändert. Deine Fassung wurde nicht gespeichert und die neuere Version wurde nicht überschrieben.</p>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card"><h5 class="card-header">Aktuelle gespeicherte Version</h5><div class="card-body">
<a href="{{ object.get_absolute_url }}" class="btn btn-primary" target="_blank">Aktuelle Version öffnen</a>
</div></div>
</div>
<div class="col-lg-6">
<div class="card"><h5 class="card-header">Dein nicht gespeicherter Inhalt</h5><div class="card-body">
<p><strong>Titel:</strong> {{ submitted_title }}</p>
<textarea class="form-control font-monospace" rows="24" readonly>{{ submitted_body }}</textarea>
<p class="text-muted mt-2">Diesen Inhalt kannst du kopieren und nach Prüfung in die aktuelle Version übernehmen.</p>
</div></div>
</div>
</div>
{% endblock content %}
@@ -1,25 +0,0 @@
{% extends 'base/layout.html' %}
{% block title %}Dokumentation wird bearbeitet{% endblock %}
{% block content %}
<div class="row justify-content-center"><div class="col-lg-7">
<div class="card border-warning">
<h5 class="card-header"><i class="mdi mdi-lock-clock"></i> Bearbeitung derzeit gesperrt</h5>
<div class="card-body">
{% if lost %}
<p>Deine Bearbeitungssperre ist nicht mehr gültig. Der Inhalt wurde nicht überschrieben.</p>
{% elif lock.user %}
<p><strong>{{ lock.user }}</strong> bearbeitet „{{ object.title }}“ momentan.</p>
{% else %}
<p>Diese Dokumentation besitzt momentan eine aktive Bearbeitungssperre.</p>
{% endif %}
<p class="text-muted">Ohne Heartbeat läuft eine Sperre nach {{ timeout }} Minuten automatisch ab.</p>
<div class="btn-list">
<a href="{{ object.get_absolute_url }}" class="btn btn-outline-secondary">Zur Dokumentation</a>
<a href="{% url 'plugins:netbox_documentation:document_edit' pk=object.pk %}" class="btn btn-primary">Erneut versuchen</a>
</div>
</div>
</div>
</div></div>
{% endblock content %}
-1
View File
@@ -14,7 +14,6 @@ urlpatterns = (
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
path("documents/<int:pk>/print/", views.DocumentPrintView.as_view(), name="document_print"),
path("documents/<int:pk>/edit/", views.DocumentEditView.as_view(), name="document_edit"),
path("documents/<int:pk>/edit-lock/", views.DocumentLockView.as_view(), name="document_lock"),
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}),
+1 -94
View File
@@ -2,7 +2,6 @@ from pathlib import Path
from datetime import timedelta
import mimetypes
import tinymce
import uuid
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -24,7 +23,7 @@ from .archive import ArchiveFailure, documents_for_categories, export_documents,
from .importers import ImportFailure, import_document, import_excel_sheets
from .models import (
Document, DocumentAssignment, DocumentAttachment, DocumentCategory,
DocumentEditLock, ExcelImportPreview,
ExcelImportPreview,
)
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
@@ -72,98 +71,6 @@ class DocumentEditView(generic.ObjectEditView):
form = DocumentForm
template_name = "netbox_documentation/document_edit.html"
@staticmethod
def version_value(document):
return str(int(document.last_updated.timestamp() * 1_000_000)) if document.last_updated else "0"
def editable_document(self, request, pk):
return get_object_or_404(Document.objects.restrict(request.user, "change"), pk=pk)
def acquire_lock(self, request, document):
timeout = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("edit_lock_timeout_minutes", 5)
expires_before = timezone.now() - timedelta(minutes=timeout)
DocumentEditLock.objects.filter(document__isnull=True).delete()
with transaction.atomic():
lock, created = DocumentEditLock.objects.get_or_create(
document=document,
defaults={"user": request.user, "token": uuid.uuid4()},
)
if not created:
lock = DocumentEditLock.objects.select_for_update().get(pk=lock.pk)
if lock.user_id != request.user.pk and lock.heartbeat >= expires_before:
return None, lock
if lock.user_id != request.user.pk or lock.heartbeat < expires_before:
lock.user = request.user
lock.token = uuid.uuid4()
lock.acquired = timezone.now()
lock.heartbeat = timezone.now()
lock.save(update_fields=("user", "token", "acquired", "heartbeat"))
return lock, None
def get(self, request, *args, **kwargs):
if "pk" not in kwargs:
return super().get(request, *args, **kwargs)
document = self.editable_document(request, kwargs["pk"])
lock, blocked_by = self.acquire_lock(request, document)
if blocked_by:
return render(request, "netbox_documentation/document_locked.html", {
"object": document, "lock": blocked_by,
"timeout": settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("edit_lock_timeout_minutes", 5),
}, status=423)
initial = request.GET.copy()
initial["edit_lock_token"] = str(lock.token)
initial["expected_version"] = self.version_value(document)
request.GET = initial
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if "pk" not in kwargs:
return super().post(request, *args, **kwargs)
document = self.editable_document(request, kwargs["pk"])
token = request.POST.get("edit_lock_token")
try:
token_value = uuid.UUID(token or "")
except (ValueError, TypeError, AttributeError):
token_value = None
lock = DocumentEditLock.objects.filter(document=document, user=request.user, token=token_value).first() if token_value else None
if not lock:
return render(request, "netbox_documentation/document_locked.html", {
"object": document, "lock": getattr(document, "edit_lock", None),
"timeout": settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("edit_lock_timeout_minutes", 5),
"lost": True,
}, status=409)
if request.POST.get("expected_version") != self.version_value(document):
DocumentEditLock.objects.filter(pk=lock.pk).delete()
return render(request, "netbox_documentation/document_edit_conflict.html", {
"object": document,
"submitted_title": request.POST.get("title", ""),
"submitted_body": request.POST.get("body", ""),
}, status=409)
response = super().post(request, *args, **kwargs)
if 300 <= response.status_code < 400 or response.headers.get("HX-Location"):
DocumentEditLock.objects.filter(pk=lock.pk, token=token).delete()
return response
class DocumentLockView(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)
token = request.POST.get("token")
try:
token_value = uuid.UUID(token or "")
except (ValueError, TypeError, AttributeError):
return JsonResponse({"error": "Ungültige Bearbeitungssperre."}, status=400)
lock = DocumentEditLock.objects.filter(document=document, user=request.user, token=token_value)
if request.POST.get("action") == "release":
deleted, _ = lock.delete()
return JsonResponse({"released": bool(deleted)})
updated = lock.update(heartbeat=timezone.now())
if not updated:
return JsonResponse({"error": "Bearbeitungssperre ist nicht mehr gültig."}, status=409)
return JsonResponse({"ok": True})
class DocumentDeleteView(generic.ObjectDeleteView):
queryset = Document.objects.all()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-documentation"
version = "0.8.0"
version = "0.7.0"
description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md"
requires-python = ">=3.10"