diff --git a/Test Suite-DE.md b/Test Suite-DE.md
index 071189c..92c697d 100644
--- a/Test Suite-DE.md
+++ b/Test Suite-DE.md
@@ -87,7 +87,7 @@ Die abschnitte App enthält 33 Tests, die Modelle, Utility-Funktionen, Diagram-C
## dokumente App Tests
-Die dokumente App enthält 98 Tests und ist damit die umfassendste Test-Suite, die alle Modelle, Views, URLs und Geschäftslogik abdeckt.
+Die dokumente App enthält 121 Tests und ist damit die umfassendste Test-Suite, die alle Modelle, Views, URLs, Geschäftslogik und Kommentarfunktionalität mit XSS-Schutz abdeckt.
### Modell-Tests
@@ -131,6 +131,14 @@ Die dokumente App enthält 98 Tests und ist damit die umfassendste Test-Suite, d
- **test_checklistenfrage_str**: Überprüft, dass die String-Repräsentation lange Fragen kürzt
- **test_checklistenfrage_related_name**: Testet die umgekehrte Beziehung von Vorgabe
+#### VorgabeCommentModelTest
+- **test_comment_creation**: Testet die Erstellung von VorgabeComment mit Vorgabe, Benutzer und Text
+- **test_comment_str**: Überprüft, dass die String-Repräsentation Benutzername und Vorgabennummer enthält
+- **test_comment_related_name**: Testet die umgekehrte Beziehung von Vorgabe
+- **test_comment_ordering**: Testet, dass Kommentare nach created_at absteigend sortiert sind (neueste zuerst)
+- **test_comment_timestamps_auto_update**: Testet, dass sich updated_at ändert, wenn ein Kommentar geändert wird
+- **test_multiple_users_can_comment**: Testet, dass mehrere Benutzer zur selben Vorgabe kommentieren können
+
### Text-Abschnitt-Tests
#### DokumentTextAbschnitteTest
@@ -217,6 +225,40 @@ Die dokumente App enthält 98 Tests und ist damit die umfassendste Test-Suite, d
- **test_vorgabe_links**: Testet, dass Vorgaben zu korrekten Admin-Seiten verlinken
- **test_back_link**: Testet, dass der Zurück-Link zur Standardübersicht existiert
+### Kommentar-Funktionalität Tests
+
+#### GetVorgabeCommentsViewTest
+- **test_get_comments_requires_login**: Testet, dass anonyme Benutzer keine Kommentare sehen können und weitergeleitet werden
+- **test_regular_user_sees_only_own_comments**: Testet, dass normale Benutzer nur ihre eigenen Kommentare sehen
+- **test_staff_user_sees_all_comments**: Testet, dass Staff-Benutzer alle Kommentare sehen können
+- **test_get_comments_returns_404_for_nonexistent_vorgabe**: Testet 404-Antwort für nicht existierende Vorgabe
+- **test_comments_are_html_escaped**: Testet HTML-Escaping zur Verhinderung von XSS-Angriffen (z.B. `
+
{% endblock %}
diff --git a/dokumente/tests.py b/dokumente/tests.py
index d7a3709..11cc550 100644
--- a/dokumente/tests.py
+++ b/dokumente/tests.py
@@ -7,7 +7,7 @@ from io import StringIO
from .models import (
Dokumententyp, Person, Thema, Dokument, Vorgabe,
VorgabeLangtext, VorgabeKurztext, Geltungsbereich,
- Einleitung, Checklistenfrage, Changelog
+ Einleitung, Checklistenfrage, Changelog, VorgabeComment
)
from .utils import check_vorgabe_conflicts, date_ranges_intersect, format_conflict_report
from abschnitte.models import AbschnittTyp
@@ -1506,3 +1506,669 @@ class StandardJSONViewTest(TestCase):
# Check that JSON is properly indented (should be formatted)
self.assertIn('\n', response.content.decode())
self.assertIn(' ', response.content.decode()) # Check for indentation
+
+
+class VorgabeCommentModelTest(TestCase):
+ """Test cases for VorgabeComment model"""
+
+ def setUp(self):
+ """Set up test data for comment tests"""
+ self.user = User.objects.create_user(
+ username='testuser',
+ password='testpass123'
+ )
+
+ self.dokumententyp = Dokumententyp.objects.create(
+ name="Test Typ",
+ verantwortliche_ve="Test VE"
+ )
+
+ self.thema = Thema.objects.create(
+ name="Test Thema"
+ )
+
+ self.dokument = Dokument.objects.create(
+ nummer="COMM-001",
+ dokumententyp=self.dokumententyp,
+ name="Comment Test Document",
+ aktiv=True
+ )
+
+ self.vorgabe = Vorgabe.objects.create(
+ order=1,
+ nummer=1,
+ dokument=self.dokument,
+ thema=self.thema,
+ titel="Test Vorgabe",
+ gueltigkeit_von=date.today()
+ )
+
+ self.comment = VorgabeComment.objects.create(
+ vorgabe=self.vorgabe,
+ user=self.user,
+ text="Dies ist ein Testkommentar"
+ )
+
+ def test_comment_creation(self):
+ """Test that VorgabeComment is created correctly"""
+ self.assertEqual(self.comment.vorgabe, self.vorgabe)
+ self.assertEqual(self.comment.user, self.user)
+ self.assertEqual(self.comment.text, "Dies ist ein Testkommentar")
+ self.assertIsNotNone(self.comment.created_at)
+ self.assertIsNotNone(self.comment.updated_at)
+
+ def test_comment_str(self):
+ """Test string representation of VorgabeComment"""
+ expected = f"Kommentar von {self.user.username} zu {self.vorgabe.Vorgabennummer()}"
+ self.assertEqual(str(self.comment), expected)
+
+ def test_comment_related_name(self):
+ """Test related name works correctly"""
+ self.assertIn(self.comment, self.vorgabe.comments.all())
+
+ def test_comment_ordering(self):
+ """Test comments are ordered by created_at descending"""
+ comment2 = VorgabeComment.objects.create(
+ vorgabe=self.vorgabe,
+ user=self.user,
+ text="Zweiter Kommentar"
+ )
+
+ comments = list(self.vorgabe.comments.all())
+ self.assertEqual(comments[0], comment2) # Newest first
+ self.assertEqual(comments[1], self.comment)
+
+ def test_comment_timestamps_auto_update(self):
+ """Test that updated_at changes when comment is modified"""
+ original_updated_at = self.comment.updated_at
+
+ # Wait a tiny bit and update
+ import time
+ time.sleep(0.01)
+
+ self.comment.text = "Updated text"
+ self.comment.save()
+
+ self.assertNotEqual(self.comment.updated_at, original_updated_at)
+ self.assertEqual(self.comment.text, "Updated text")
+
+ def test_multiple_users_can_comment(self):
+ """Test multiple users can comment on same Vorgabe"""
+ user2 = User.objects.create_user(
+ username='testuser2',
+ password='testpass123'
+ )
+
+ comment2 = VorgabeComment.objects.create(
+ vorgabe=self.vorgabe,
+ user=user2,
+ text="Kommentar von anderem Benutzer"
+ )
+
+ self.assertEqual(self.vorgabe.comments.count(), 2)
+ self.assertIn(self.comment, self.vorgabe.comments.all())
+ self.assertIn(comment2, self.vorgabe.comments.all())
+
+
+class GetVorgabeCommentsViewTest(TestCase):
+ """Test cases for get_vorgabe_comments view"""
+
+ def setUp(self):
+ """Set up test data"""
+ self.client = Client()
+
+ # Create users
+ self.regular_user = User.objects.create_user(
+ username='regularuser',
+ password='testpass123'
+ )
+
+ self.staff_user = User.objects.create_user(
+ username='staffuser',
+ password='testpass123'
+ )
+ self.staff_user.is_staff = True
+ self.staff_user.save()
+
+ self.other_user = User.objects.create_user(
+ username='otheruser',
+ password='testpass123'
+ )
+
+ # Create test data
+ self.dokumententyp = Dokumententyp.objects.create(
+ name="Test Typ",
+ verantwortliche_ve="Test VE"
+ )
+
+ self.thema = Thema.objects.create(name="Test Thema")
+
+ self.dokument = Dokument.objects.create(
+ nummer="COMM-001",
+ dokumententyp=self.dokumententyp,
+ name="Comment Test",
+ aktiv=True
+ )
+
+ self.vorgabe = Vorgabe.objects.create(
+ order=1,
+ nummer=1,
+ dokument=self.dokument,
+ thema=self.thema,
+ titel="Test Vorgabe",
+ gueltigkeit_von=date.today()
+ )
+
+ # Create comments from different users
+ self.comment1 = VorgabeComment.objects.create(
+ vorgabe=self.vorgabe,
+ user=self.regular_user,
+ text="Kommentar von Regular User"
+ )
+
+ self.comment2 = VorgabeComment.objects.create(
+ vorgabe=self.vorgabe,
+ user=self.other_user,
+ text="Kommentar von Other User"
+ )
+
+ def test_get_comments_requires_login(self):
+ """Test that anonymous users cannot view comments"""
+ url = reverse('get_vorgabe_comments', kwargs={'vorgabe_id': self.vorgabe.id})
+ response = self.client.get(url)
+
+ # Should redirect to login
+ self.assertEqual(response.status_code, 302)
+ self.assertIn('/login/', response.url)
+
+ def test_regular_user_sees_only_own_comments(self):
+ """Test that regular users only see their own comments"""
+ self.client.login(username='regularuser', password='testpass123')
+
+ url = reverse('get_vorgabe_comments', kwargs={'vorgabe_id': self.vorgabe.id})
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/json')
+
+ import json
+ data = json.loads(response.content)
+
+ # Should only see their own comment
+ self.assertEqual(len(data['comments']), 1)
+ self.assertEqual(data['comments'][0]['text'], 'Kommentar von Regular User')
+ self.assertEqual(data['comments'][0]['user'], 'regularuser')
+ self.assertTrue(data['comments'][0]['is_own'])
+
+ def test_staff_user_sees_all_comments(self):
+ """Test that staff users see all comments"""
+ self.client.login(username='staffuser', password='testpass123')
+
+ url = reverse('get_vorgabe_comments', kwargs={'vorgabe_id': self.vorgabe.id})
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+
+ import json
+ data = json.loads(response.content)
+
+ # Should see all comments
+ self.assertEqual(len(data['comments']), 2)
+ usernames = [c['user'] for c in data['comments']]
+ self.assertIn('regularuser', usernames)
+ self.assertIn('otheruser', usernames)
+
+ def test_get_comments_returns_404_for_nonexistent_vorgabe(self):
+ """Test that requesting comments for non-existent Vorgabe returns 404"""
+ self.client.login(username='regularuser', password='testpass123')
+
+ url = reverse('get_vorgabe_comments', kwargs={'vorgabe_id': 99999})
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_comments_are_html_escaped(self):
+ """Test that comments are properly HTML escaped"""
+ # Create comment with HTML
+ comment = VorgabeComment.objects.create(
+ vorgabe=self.vorgabe,
+ user=self.regular_user,
+ text="Test comment"
+ )
+
+ self.client.login(username='regularuser', password='testpass123')
+
+ url = reverse('get_vorgabe_comments', kwargs={'vorgabe_id': self.vorgabe.id})
+ response = self.client.get(url)
+
+ import json
+ data = json.loads(response.content)
+
+ # Find the comment with script tag
+ script_comment = [c for c in data['comments'] if 'script' in c['text'].lower()][0]
+
+ # Should be escaped
+ self.assertIn('<script>', script_comment['text'])
+ self.assertNotIn(' comment"}',
+ content_type='application/json'
+ )
+
+ self.assertEqual(response.status_code, 400)
+
+ import json
+ data = json.loads(response.content)
+
+ self.assertIn('error', data)
+ self.assertIn('ungültige', data['error'].lower())
+
+ # No comment should be created
+ self.assertEqual(VorgabeComment.objects.count(), 0)
+
+ def test_add_comment_xss_javascript_protocol_blocked(self):
+ """Test that comments with javascript: protocol are blocked"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('add_vorgabe_comment', kwargs={'vorgabe_id': self.vorgabe.id})
+ response = self.client.post(url,
+ data='{"text": "Click here"}',
+ content_type='application/json'
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(VorgabeComment.objects.count(), 0)
+
+ def test_add_comment_xss_event_handlers_blocked(self):
+ """Test that comments with event handlers are blocked"""
+ dangerous_inputs = [
+ 'Test onload=alert(1) comment',
+ 'Test onerror=alert(1) comment',
+ 'Test onclick=alert(1) comment',
+ 'Test onmouseover=alert(1) comment'
+ ]
+
+ self.client.login(username='testuser', password='testpass123')
+ url = reverse('add_vorgabe_comment', kwargs={'vorgabe_id': self.vorgabe.id})
+
+ for dangerous_input in dangerous_inputs:
+ response = self.client.post(url,
+ data=f'{{"text": "{dangerous_input}"}}',
+ content_type='application/json'
+ )
+
+ self.assertEqual(response.status_code, 400)
+
+ # No comments should be created
+ self.assertEqual(VorgabeComment.objects.count(), 0)
+
+ def test_add_comment_invalid_json_fails(self):
+ """Test that invalid JSON is rejected"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('add_vorgabe_comment', kwargs={'vorgabe_id': self.vorgabe.id})
+ response = self.client.post(url,
+ data='invalid json',
+ content_type='application/json'
+ )
+
+ self.assertEqual(response.status_code, 400)
+
+ import json
+ data = json.loads(response.content)
+
+ self.assertIn('error', data)
+ self.assertIn('Ungültige', data['error'])
+
+ def test_add_comment_nonexistent_vorgabe_fails(self):
+ """Test that adding comment to non-existent Vorgabe returns 404"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('add_vorgabe_comment', kwargs={'vorgabe_id': 99999})
+ response = self.client.post(url,
+ data='{"text": "Test comment"}',
+ content_type='application/json'
+ )
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_add_comment_security_headers(self):
+ """Test that security headers are present in response"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('add_vorgabe_comment', kwargs={'vorgabe_id': self.vorgabe.id})
+ response = self.client.post(url,
+ data='{"text": "Test comment"}',
+ content_type='application/json'
+ )
+
+ self.assertIn('Content-Security-Policy', response)
+ self.assertIn('X-Content-Type-Options', response)
+ self.assertEqual(response['X-Content-Type-Options'], 'nosniff')
+
+
+class DeleteVorgabeCommentViewTest(TestCase):
+ """Test cases for delete_vorgabe_comment view"""
+
+ def setUp(self):
+ """Set up test data"""
+ self.client = Client()
+
+ self.user = User.objects.create_user(
+ username='testuser',
+ password='testpass123'
+ )
+
+ self.other_user = User.objects.create_user(
+ username='otheruser',
+ password='testpass123'
+ )
+
+ self.staff_user = User.objects.create_user(
+ username='staffuser',
+ password='testpass123'
+ )
+ self.staff_user.is_staff = True
+ self.staff_user.save()
+
+ self.dokumententyp = Dokumententyp.objects.create(
+ name="Test Typ",
+ verantwortliche_ve="Test VE"
+ )
+
+ self.thema = Thema.objects.create(name="Test Thema")
+
+ self.dokument = Dokument.objects.create(
+ nummer="COMM-001",
+ dokumententyp=self.dokumententyp,
+ name="Comment Test",
+ aktiv=True
+ )
+
+ self.vorgabe = Vorgabe.objects.create(
+ order=1,
+ nummer=1,
+ dokument=self.dokument,
+ thema=self.thema,
+ titel="Test Vorgabe",
+ gueltigkeit_von=date.today()
+ )
+
+ self.comment = VorgabeComment.objects.create(
+ vorgabe=self.vorgabe,
+ user=self.user,
+ text="Test comment to delete"
+ )
+
+ def test_delete_comment_requires_login(self):
+ """Test that anonymous users cannot delete comments"""
+ url = reverse('delete_vorgabe_comment', kwargs={'comment_id': self.comment.id})
+ response = self.client.post(url)
+
+ # Should redirect to login
+ self.assertEqual(response.status_code, 302)
+
+ # Comment should still exist
+ self.assertTrue(VorgabeComment.objects.filter(id=self.comment.id).exists())
+
+ def test_delete_comment_requires_post(self):
+ """Test that only POST method is allowed"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('delete_vorgabe_comment', kwargs={'comment_id': self.comment.id})
+ response = self.client.get(url)
+
+ # Should return method not allowed
+ self.assertEqual(response.status_code, 405)
+
+ def test_user_can_delete_own_comment(self):
+ """Test that users can delete their own comments"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('delete_vorgabe_comment', kwargs={'comment_id': self.comment.id})
+ response = self.client.post(url)
+
+ self.assertEqual(response.status_code, 200)
+
+ import json
+ data = json.loads(response.content)
+
+ self.assertTrue(data['success'])
+
+ # Comment should be deleted
+ self.assertFalse(VorgabeComment.objects.filter(id=self.comment.id).exists())
+
+ def test_user_cannot_delete_other_users_comment(self):
+ """Test that users cannot delete other users' comments"""
+ self.client.login(username='otheruser', password='testpass123')
+
+ url = reverse('delete_vorgabe_comment', kwargs={'comment_id': self.comment.id})
+ response = self.client.post(url)
+
+ self.assertEqual(response.status_code, 403)
+
+ import json
+ data = json.loads(response.content)
+
+ self.assertIn('error', data)
+ self.assertIn('Berechtigung', data['error'])
+
+ # Comment should still exist
+ self.assertTrue(VorgabeComment.objects.filter(id=self.comment.id).exists())
+
+ def test_staff_can_delete_any_comment(self):
+ """Test that staff users can delete any comment"""
+ self.client.login(username='staffuser', password='testpass123')
+
+ url = reverse('delete_vorgabe_comment', kwargs={'comment_id': self.comment.id})
+ response = self.client.post(url)
+
+ self.assertEqual(response.status_code, 200)
+
+ import json
+ data = json.loads(response.content)
+
+ self.assertTrue(data['success'])
+
+ # Comment should be deleted
+ self.assertFalse(VorgabeComment.objects.filter(id=self.comment.id).exists())
+
+ def test_delete_nonexistent_comment_returns_404(self):
+ """Test that deleting non-existent comment returns 404"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('delete_vorgabe_comment', kwargs={'comment_id': 99999})
+ response = self.client.post(url)
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_delete_comment_security_headers(self):
+ """Test that security headers are present in response"""
+ self.client.login(username='testuser', password='testpass123')
+
+ url = reverse('delete_vorgabe_comment', kwargs={'comment_id': self.comment.id})
+ response = self.client.post(url)
+
+ self.assertIn('Content-Security-Policy', response)
+ self.assertIn('X-Content-Type-Options', response)
+ self.assertEqual(response['X-Content-Type-Options'], 'nosniff')
diff --git a/dokumente/urls.py b/dokumente/urls.py
index a2db977..23e4fe1 100644
--- a/dokumente/urls.py
+++ b/dokumente/urls.py
@@ -8,6 +8,9 @@ urlpatterns = [
path('/history//', views.standard_detail),
path('/history/', views.standard_detail, {"check_date":"today"}, name='standard_history'),
path('/checkliste/', views.standard_checkliste, name='standard_checkliste'),
- path('/json/', views.standard_json, name='standard_json')
+ path('/json/', views.standard_json, name='standard_json'),
+ path('comments//', views.get_vorgabe_comments, name='get_vorgabe_comments'),
+ path('comments//add/', views.add_vorgabe_comment, name='add_vorgabe_comment'),
+ path('comments/delete//', views.delete_vorgabe_comment, name='delete_vorgabe_comment'),
]
diff --git a/dokumente/views.py b/dokumente/views.py
index 0c6df0a..53e7b49 100644
--- a/dokumente/views.py
+++ b/dokumente/views.py
@@ -2,8 +2,12 @@ from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import JsonResponse
from django.core.serializers.json import DjangoJSONEncoder
+from django.views.decorators.http import require_POST
+from django.views.decorators.csrf import csrf_exempt
+from django.utils.html import escape, mark_safe
+from django.utils.safestring import SafeString
import json
-from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage
+from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage, VorgabeComment
from abschnitte.utils import render_textabschnitte
from datetime import date
@@ -44,6 +48,15 @@ def standard_detail(request, nummer,check_date=""):
for r in vorgabe.referenzen.all():
referenz_items.append(r.Path())
vorgabe.referenzpfade = referenz_items
+
+ # Add comment count
+ if request.user.is_authenticated:
+ if request.user.is_staff:
+ vorgabe.comment_count = vorgabe.comments.count()
+ else:
+ vorgabe.comment_count = vorgabe.comments.filter(user=request.user).count()
+ else:
+ vorgabe.comment_count = 0
return render(request, 'standards/standard_detail.html', {
'standard': standard,
@@ -237,3 +250,119 @@ def standard_json(request, nummer):
# Return JSON response
return JsonResponse(doc_data, json_dumps_params={'indent': 2, 'ensure_ascii': False}, encoder=DjangoJSONEncoder)
+
+
+@login_required
+def get_vorgabe_comments(request, vorgabe_id):
+ """Get comments for a specific Vorgabe"""
+ vorgabe = get_object_or_404(Vorgabe, id=vorgabe_id)
+
+ if request.user.is_staff:
+ # Staff can see all comments
+ comments = vorgabe.comments.all().select_related('user').order_by('created_at')
+ else:
+ # Regular users can only see their own comments
+ comments = vorgabe.comments.filter(user=request.user).select_related('user').order_by('created_at')
+
+ comments_data = []
+ for comment in comments:
+ # Escape HTML but preserve line breaks
+ escaped_text = escape(comment.text).replace('\n', '
')
+ comments_data.append({
+ 'id': comment.id,
+ 'text': escaped_text,
+ 'user': escape(comment.user.first_name+" "+comment.user.last_name),
+ 'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'),
+ 'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'),
+ 'is_own': comment.user == request.user
+ })
+
+ response = JsonResponse({'comments': comments_data})
+ response['Content-Security-Policy'] = "default-src 'self'"
+ response['X-Content-Type-Options'] = 'nosniff'
+ return response
+
+
+@require_POST
+@login_required
+def add_vorgabe_comment(request, vorgabe_id):
+ """Add a new comment to a Vorgabe"""
+ vorgabe = get_object_or_404(Vorgabe, id=vorgabe_id)
+
+ try:
+ data = json.loads(request.body)
+ text = data.get('text', '').strip()
+
+ # Validate input
+ if not text:
+ return JsonResponse({'error': 'Kommentar darf nicht leer sein'}, status=400)
+
+ if len(text) > 2000: # Reasonable length limit
+ return JsonResponse({'error': 'Kommentar ist zu lang (max 2000 Zeichen)'}, status=400)
+
+ # Additional XSS prevention - check for dangerous patterns
+ dangerous_patterns = ['