feat: Bearbeitungssperren und Versionskonfliktschutz ergänzen
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user