feat: Bearbeitungssperren und Versionskonfliktschutz ergänzen

This commit is contained in:
2026-07-23 13:13:34 +02:00
parent df11d0eb05
commit 601c7308ce
11 changed files with 234 additions and 5 deletions
+2 -1
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.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,
}
+2
View File
@@ -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 = (
@@ -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)),
],
),
]
+14
View File
@@ -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}"
@@ -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 %}
});
</script>
{% endblock javascript %}
{% block pre_form_fields %}
{% if not object.pk %}
{% 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 %}
<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,24 @@
{% 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 %}
@@ -0,0 +1,25 @@
{% 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,6 +14,7 @@ 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}),
+94 -1
View File
@@ -2,6 +2,7 @@ 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
@@ -23,7 +24,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,
ExcelImportPreview,
DocumentEditLock, ExcelImportPreview,
)
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
@@ -71,6 +72,98 @@ 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()