feat: Bearbeitungssperren und Versionskonfliktschutz ergänzen
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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 %}
|
||||
@@ -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}),
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netbox-documentation"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Reference in New Issue
Block a user