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 = ['') + response = JsonResponse({ + 'success': True, + 'comment': { + 'id': comment.id, + 'text': escaped_text, + 'user': escape(comment.user.username), + 'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'), + 'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'), + 'is_own': True + } + }) + response['Content-Security-Policy'] = "default-src 'self'" + response['X-Content-Type-Options'] = 'nosniff' + return response + + except json.JSONDecodeError: + response = JsonResponse({'error': 'Ungültige Daten'}, status=400) + response['Content-Security-Policy'] = "default-src 'self'" + response['X-Content-Type-Options'] = 'nosniff' + return response + except Exception as e: + response = JsonResponse({'error': 'Serverfehler'}, status=500) + response['Content-Security-Policy'] = "default-src 'self'" + response['X-Content-Type-Options'] = 'nosniff' + return response + + +@require_POST +@login_required +def delete_vorgabe_comment(request, comment_id): + """Delete a comment (only own comments or staff can delete)""" + comment = get_object_or_404(VorgabeComment, id=comment_id) + + # Check if user can delete this comment + if comment.user != request.user and not request.user.is_staff: + response = JsonResponse({'error': 'Keine Berechtigung zum Löschen dieses Kommentars'}, status=403) + response['Content-Security-Policy'] = "default-src 'self'" + response['X-Content-Type-Options'] = 'nosniff' + return response + + try: + comment.delete() + response = JsonResponse({'success': True}) + response['Content-Security-Policy'] = "default-src 'self'" + response['X-Content-Type-Options'] = 'nosniff' + return response + except Exception as e: + response = JsonResponse({'error': 'Serverfehler'}, status=500) + response['Content-Security-Policy'] = "default-src 'self'" + response['X-Content-Type-Options'] = 'nosniff' + return response diff --git a/pages/templates/base.html b/pages/templates/base.html index cd71670..09051a9 100644 --- a/pages/templates/base.html +++ b/pages/templates/base.html @@ -215,7 +215,7 @@

-

Version {{ version|default:"0.958" }}

+

Version {{ version|default:"0.960" }}

diff --git a/referenzen/migrations/0003_alter_referenzerklaerung_options.py b/referenzen/migrations/0003_alter_referenzerklaerung_options.py new file mode 100644 index 0000000..e214b19 --- /dev/null +++ b/referenzen/migrations/0003_alter_referenzerklaerung_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.5 on 2025-11-27 22:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('referenzen', '0002_alter_referenz_table_alter_referenzerklaerung_table'), + ] + + operations = [ + migrations.AlterModelOptions( + name='referenzerklaerung', + options={'verbose_name': 'Erklärung', 'verbose_name_plural': 'Erklärungen'}, + ), + ] diff --git a/static/custom/css/admin_extras.css b/static/custom/css/admin_extras.css index 2e15826..29450c5 100644 --- a/static/custom/css/admin_extras.css +++ b/static/custom/css/admin_extras.css @@ -11,4 +11,93 @@ margin-bottom: 1em; border: 1px solid #ccc; padding: 0; - } \ No newline at end of file + } + +/* Comment System Styles */ +.comment-btn { + position: relative; +} + +.comment-btn .comment-count { + position: absolute; + top: -8px; + right: -8px; + background-color: #dc3545; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +.comment-item { + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.comment-item .text-muted { + font-size: 0.85em; +} + +#commentModal .modal-body { + max-height: 60vh; + overflow-y: auto; +} + +#commentsContainer { + min-height: 100px; +} + +.delete-comment-btn { + opacity: 0.7; + transition: opacity 0.2s; +} + +.delete-comment-btn:hover { + opacity: 1; +} + +.delete-comment-btn { + font-size: 18px; + font-weight: bold; + line-height: 1; + color: #721c24; + border: 1px solid #f5c6cb; + border-radius: 4px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.delete-comment-btn:hover { + opacity: 1; + background-color: #f8d7da; + border-color: #f5c6cb; +} + +/* Icon styling for emoji replacements */ +.emoji-icon { + font-size: 1.1em; + margin-right: 0.3em; + vertical-align: middle; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .comment-item .d-flex { + flex-direction: column; + } + + .delete-comment-btn { + margin-left: 0 !important; + margin-top: 0.5rem; + } +} \ No newline at end of file diff --git a/stichworte/migrations/0003_alter_stichworterklaerung_options.py b/stichworte/migrations/0003_alter_stichworterklaerung_options.py new file mode 100644 index 0000000..948e9f4 --- /dev/null +++ b/stichworte/migrations/0003_alter_stichworterklaerung_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.5 on 2025-11-27 22:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stichworte', '0002_stichworterklaerung_order'), + ] + + operations = [ + migrations.AlterModelOptions( + name='stichworterklaerung', + options={'verbose_name': 'Erklärung', 'verbose_name_plural': 'Erklärungen'}, + ), + ]