diff --git a/admin/css/vorgabe_border.css b/admin/css/vorgabe_border.css new file mode 100644 index 0000000..68440be --- /dev/null +++ b/admin/css/vorgabe_border.css @@ -0,0 +1,102 @@ +/* Better visual separation for Vorgaben inlines */ +.inline-group[data-inline-model="vorgabe"] { + border: 2px solid #ddd; + border-radius: 8px; + margin-bottom: 20px; + padding: 15px; + background-color: #f9f9f9; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.inline-group[data-inline-model="vorgabe"] .inline-related { + border: 1px solid #ccc; + border-radius: 6px; + margin-bottom: 10px; + background-color: white; + padding: 10px; +} + +.inline-group[data-inline-model="vorgabe"] h3 { + background-color: #007cba; + color: white; + padding: 8px 12px; + margin: -15px -15px 10px -15px; + border-radius: 6px 6px 0 0; + font-weight: bold; +} + +.inline-group[data-inline-model="vorgabe"] .collapse .inline-related { + border-left: 3px solid #007cba; +} + +/* Better spacing for nested inlines */ +.inline-group[data-inline-model="vorgabe"] .inline-group { + margin-top: 10px; +} + +.inline-group[data-inline-model="vorgabe"] .inline-group h3 { + background-color: #f0f8ff; + color: #333; + padding: 6px 10px; + margin: 0 0 8px 0; + border-left: 3px solid #007cba; +} + +/* Highlight active/expanded vorgabe */ +.inline-group[data-inline-model="vorgabe"] .inline-related:not(.collapsed) { + border-color: #007cba; + box-shadow: 0 0 8px rgba(0,124,186,0.2); +} + +/* Highlight actively edited vorgabe */ +.inline-group[data-inline-model="vorgabe"] .inline-related.active-edit { + border-color: #28a745; + box-shadow: 0 0 12px rgba(40,167,69,0.3); + background-color: #f8fff9; +} + +/* Toggle hint styling */ +.toggle-hint { + font-size: 0.8em; + color: #666; + font-weight: normal; +} + +/* Better fieldset styling for vorgabe inlines */ +.inline-group[data-inline-model="vorgabe"] .fieldset { + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 10px; + margin-bottom: 10px; + background-color: #fafafa; +} + +.inline-group[data-inline-model="vorgabe"] .fieldset h2 { + background-color: #e3f2fd; + color: #1565c0; + padding: 5px 10px; + margin: -10px -10px 10px -10px; + border-radius: 4px 4px 0 0; + font-size: 0.9em; + font-weight: bold; +} + +/* Better form layout */ +.inline-group[data-inline-model="vorgabe"] .form-row { + border-bottom: 1px solid #eee; + padding: 8px 0; +} + +.inline-group[data-inline-model="vorgabe"] .form-row:last-child { + border-bottom: none; +} + +/* Wide fields styling */ +.inline-group[data-inline-model="vorgabe"] .wide .form-row > div { + width: 100%; +} + +.inline-group[data-inline-model="vorgabe"] .wide textarea { + width: 100%; + min-height: 80px; +} \ No newline at end of file diff --git a/admin/js/vorgabe_toggle.js b/admin/js/vorgabe_toggle.js new file mode 100644 index 0000000..b6a4168 --- /dev/null +++ b/admin/js/vorgabe_toggle.js @@ -0,0 +1,25 @@ +(function($) { + 'use strict'; + + $(document).ready(function() { + // Add toggle buttons for each vorgabe inline + $('.inline-group[data-inline-model="vorgabe"]').each(function() { + var $group = $(this); + var $headers = $group.find('h3'); + + $headers.css('cursor', 'pointer').append(' (klicken zum umschalten)'); + + $headers.on('click', function(e) { + e.preventDefault(); + var $inline = $(this).closest('.inline-related'); + $inline.find('.collapse').toggleClass('collapsed'); + }); + }); + + // Highlight active vorgabe when editing + $('.inline-group[data-inline-model="vorgabe"] .inline-related').on('click', function() { + $('.inline-group[data-inline-model="vorgabe"] .inline-related').removeClass('active-edit'); + $(this).addClass('active-edit'); + }); + }); +})(django.jQuery); \ No newline at end of file diff --git a/dokumente/admin.py b/dokumente/admin.py index 3fb238a..f2002ad 100644 --- a/dokumente/admin.py +++ b/dokumente/admin.py @@ -21,45 +21,75 @@ from referenzen.models import Referenz # 'frage': forms.Textarea(attrs={'rows': 1, 'cols': 100}), # } -class ChecklistenfragenInline(NestedTabularInline): +class ChecklistenfragenInline(NestedStackedInline): model=Checklistenfrage extra=0 fk_name="vorgabe" -# form=ChecklistenForm classes = ['collapse'] + verbose_name_plural = "Checklistenfragen" + fieldsets = ( + (None, { + 'fields': ('frage',), + 'classes': ('wide',), + }), + ) -class VorgabeKurztextInline(NestedTabularInline): +class VorgabeKurztextInline(NestedStackedInline): model=VorgabeKurztext extra=0 sortable_field_name = "order" show_change_link=True classes = ['collapse'] - #inline=inhalt + verbose_name_plural = "Kurztext-Abschnitte" + fieldsets = ( + (None, { + 'fields': ('abschnitttyp', 'inhalt', 'order'), + 'classes': ('wide',), + }), + ) -class VorgabeLangtextInline(NestedTabularInline): +class VorgabeLangtextInline(NestedStackedInline): model=VorgabeLangtext extra=0 sortable_field_name = "order" show_change_link=True classes = ['collapse'] - #inline=inhalt + verbose_name_plural = "Langtext-Abschnitte" + fieldsets = ( + (None, { + 'fields': ('abschnitttyp', 'inhalt', 'order'), + 'classes': ('wide',), + }), + ) -class GeltungsbereichInline(NestedTabularInline): +class GeltungsbereichInline(NestedStackedInline): model=Geltungsbereich extra=0 sortable_field_name = "order" show_change_link=True classes = ['collapse'] - classes = ['collapse'] - #inline=inhalt + verbose_name_plural = "Geltungsbereich-Abschnitte" + fieldsets = ( + (None, { + 'fields': ('abschnitttyp', 'inhalt', 'order'), + 'classes': ('wide',), + }), + ) -class EinleitungInline(NestedTabularInline): - model = Einleitung - extra = 0 - sortable_field_name = "order" - show_change_link = True - classes = ['collapse'] +class EinleitungInline(NestedStackedInline): + model = Einleitung + extra = 0 + sortable_field_name = "order" + show_change_link = True + classes = ['collapse'] + verbose_name_plural = "Einleitungs-Abschnitte" + fieldsets = ( + (None, { + 'fields': ('abschnitttyp', 'inhalt', 'order'), + 'classes': ('wide',), + }), + ) class VorgabeForm(forms.ModelForm): referenzen = TreeNodeMultipleChoiceField(queryset=Referenz.objects.all(), required=False) @@ -67,17 +97,31 @@ class VorgabeForm(forms.ModelForm): model = Vorgabe fields = '__all__' -class VorgabeInline(SortableInlineAdminMixin, NestedTabularInline): # or StackedInline for more vertical layout +class VorgabeInline(SortableInlineAdminMixin, NestedStackedInline): model = Vorgabe form = VorgabeForm extra = 0 - sortable_field_name = "order" # Add this - make sure your Vorgabe model has an 'order' field - #show_change_link = True - inlines = [VorgabeKurztextInline,VorgabeLangtextInline,ChecklistenfragenInline] + sortable_field_name = "order" + show_change_link = False + can_delete = False + inlines = [VorgabeKurztextInline, VorgabeLangtextInline, ChecklistenfragenInline] autocomplete_fields = ['stichworte','referenzen','relevanz'] - #search_fields=['nummer','name']ModelAdmin. - list_filter=['stichworte'] - #classes=["collapse"] + # Remove collapse class so Vorgaben show by default + + fieldsets = ( + ('Grunddaten', { + 'fields': (('order', 'nummer'), ('thema', 'titel')), + 'classes': ('wide',), + }), + ('Gültigkeit', { + 'fields': (('gueltigkeit_von', 'gueltigkeit_bis'),), + 'classes': ('wide',), + }), + ('Verknüpfungen', { + 'fields': (('referenzen', 'stichworte', 'relevanz'),), + 'classes': ('wide',), + }), + ) class StichworterklaerungInline(NestedTabularInline): model=Stichworterklaerung @@ -104,16 +148,31 @@ class PersonAdmin(admin.ModelAdmin): @admin.register(Dokument) class DokumentAdmin(SortableAdminBase, NestedModelAdmin): actions_on_top=True - inlines = [EinleitungInline,GeltungsbereichInline,VorgabeInline] - #filter_horizontal=['autoren','pruefende'] - list_display=['nummer','name','dokumententyp'] + inlines = [EinleitungInline, GeltungsbereichInline, VorgabeInline] + filter_horizontal=['autoren','pruefende'] + list_display=['nummer','name','dokumententyp','gueltigkeit_von','gueltigkeit_bis','aktiv'] search_fields=['nummer','name'] + list_filter=['dokumententyp','aktiv','gueltigkeit_von'] + + fieldsets = ( + ('Grunddaten', { + 'fields': ('nummer', 'name', 'dokumententyp', 'aktiv'), + 'classes': ('wide',), + }), + ('Verantwortlichkeiten', { + 'fields': ('autoren', 'pruefende'), + 'classes': ('wide', 'collapse'), + }), + ('Gültigkeit & Metadaten', { + 'fields': ('gueltigkeit_von', 'gueltigkeit_bis', 'signatur_cso', 'anhaenge'), + 'classes': ('wide', 'collapse'), + }), + ) + class Media: -# js = ('admin/js/vorgabe_collapse.js',) + js = ('admin/js/vorgabe_collapse.js',) css = { - 'all': ('admin/css/vorgabe_border.css', -# 'admin/css/vorgabe_collapse.css', - ) + 'all': ('admin/css/vorgabe_border.css',) } diff --git a/dokumente/management/commands/sanity_check_vorgaben.py b/dokumente/management/commands/sanity_check_vorgaben.py new file mode 100644 index 0000000..5d6d278 --- /dev/null +++ b/dokumente/management/commands/sanity_check_vorgaben.py @@ -0,0 +1,70 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from dokumente.models import Vorgabe +import datetime + + +class Command(BaseCommand): + help = 'Run sanity checks on Vorgaben to detect conflicts' + + def add_arguments(self, parser): + parser.add_argument( + '--fix', + action='store_true', + help='Attempt to fix conflicts (not implemented yet)', + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Show detailed output', + ) + + def handle(self, *args, **options): + self.verbose = options['verbose'] + + self.stdout.write(self.style.SUCCESS('Starting Vorgaben sanity check...')) + + # Run the sanity check + conflicts = Vorgabe.sanity_check_vorgaben() + + if not conflicts: + self.stdout.write(self.style.SUCCESS('✓ No conflicts found in Vorgaben')) + return + + self.stdout.write( + self.style.WARNING(f'Found {len(conflicts)} conflicts:') + ) + + for i, conflict in enumerate(conflicts, 1): + self._display_conflict(i, conflict) + + if options['fix']: + self.stdout.write(self.style.ERROR('Auto-fix not implemented yet')) + + def _display_conflict(self, index, conflict): + """Display a single conflict""" + v1 = conflict['vorgabe1'] + v2 = conflict['vorgabe2'] + + self.stdout.write(f"\n{index}. {conflict['message']}") + + if self.verbose: + self.stdout.write(f" Vorgabe 1: {v1.Vorgabennummer()}") + self.stdout.write(f" Valid from: {v1.gueltigkeit_von} to {v1.gueltigkeit_bis or 'unlimited'}") + self.stdout.write(f" Title: {v1.titel}") + + self.stdout.write(f" Vorgabe 2: {v2.Vorgabennummer()}") + self.stdout.write(f" Valid from: {v2.gueltigkeit_von} to {v2.gueltigkeit_bis or 'unlimited'}") + self.stdout.write(f" Title: {v2.titel}") + + # Show the overlapping period + overlap_start = max(v1.gueltigkeit_von, v2.gueltigkeit_von) + overlap_end = min( + v1.gueltigkeit_bis or datetime.date.max, + v2.gueltigkeit_bis or datetime.date.max + ) + + if overlap_end != datetime.date.max: + self.stdout.write(f" Overlap: {overlap_start} to {overlap_end}") + else: + self.stdout.write(f" Overlap starts: {overlap_start} (no end)") \ No newline at end of file diff --git a/dokumente/models.py b/dokumente/models.py index b7ea68c..3691fdb 100644 --- a/dokumente/models.py +++ b/dokumente/models.py @@ -5,6 +5,7 @@ from stichworte.models import Stichwort from referenzen.models import Referenz from rollen.models import Rolle import datetime +from django.db.models import Q class Dokumententyp(models.Model): name = models.CharField(max_length=100, primary_key=True) @@ -86,6 +87,123 @@ class Vorgabe(models.Model): def __str__(self): return f"{self.Vorgabennummer()}: {self.titel}" + @staticmethod + def sanity_check_vorgaben(): + """ + Sanity check for Vorgaben: + If there are two Vorgaben with the same number, Thema and Dokument, + their valid_from and valid_to date ranges shouldn't intersect. + + Returns: + list: List of dictionaries containing conflicts found + """ + conflicts = [] + + # Group Vorgaben by dokument, thema, and nummer + from django.db.models import Count + from itertools import combinations + + # Find Vorgaben with same dokument, thema, and nummer + duplicate_groups = ( + Vorgabe.objects.values('dokument', 'thema', 'nummer') + .annotate(count=Count('id')) + .filter(count__gt=1) + ) + + for group in duplicate_groups: + # Get all Vorgaben in this group + vorgaben = Vorgabe.objects.filter( + dokument=group['dokument'], + thema=group['thema'], + nummer=group['nummer'] + ) + + # Check all pairs for date range intersections + for vorgabe1, vorgabe2 in combinations(vorgaben, 2): + if Vorgabe._date_ranges_intersect( + vorgabe1.gueltigkeit_von, vorgabe1.gueltigkeit_bis, + vorgabe2.gueltigkeit_von, vorgabe2.gueltigkeit_bis + ): + conflicts.append({ + 'vorgabe1': vorgabe1, + 'vorgabe2': vorgabe2, + 'conflict_type': 'date_range_intersection', + 'message': f"Vorgaben {vorgabe1.Vorgabennummer()} and {vorgabe2.Vorgabennummer()} " + f"have intersecting validity periods" + }) + + return conflicts + + def clean(self): + """ + Validate the Vorgabe before saving. + """ + from django.core.exceptions import ValidationError + + # Check for conflicts with existing Vorgaben + conflicts = self.find_conflicts() + if conflicts: + conflict_messages = [c['message'] for c in conflicts] + raise ValidationError({ + '__all__': conflict_messages + }) + + def find_conflicts(self): + """ + Find conflicts with existing Vorgaben. + + Returns: + list: List of conflict dictionaries + """ + conflicts = [] + + # Find Vorgaben with same dokument, thema, and nummer (excluding self) + existing_vorgaben = Vorgabe.objects.filter( + dokument=self.dokument, + thema=self.thema, + nummer=self.nummer + ).exclude(pk=self.pk) + + for other_vorgabe in existing_vorgaben: + if self._date_ranges_intersect( + self.gueltigkeit_von, self.gueltigkeit_bis, + other_vorgabe.gueltigkeit_von, other_vorgabe.gueltigkeit_bis + ): + conflicts.append({ + 'vorgabe1': self, + 'vorgabe2': other_vorgabe, + 'conflict_type': 'date_range_intersection', + 'message': f"Vorgabe {self.Vorgabennummer()} conflicts with " + f"existing {other_vorgabe.Vorgabennummer()} " + f"due to overlapping validity periods" + }) + + return conflicts + + @staticmethod + def _date_ranges_intersect(start1, end1, start2, end2): + """ + Check if two date ranges intersect. + None end date means open-ended range. + + Args: + start1, start2: Start dates + end1, end2: End dates (can be None for open-ended) + + Returns: + bool: True if ranges intersect + """ + # If either start date is None, treat it as invalid case + if not start1 or not start2: + return False + + # If end date is None, treat it as far future + end1 = end1 or datetime.date.max + end2 = end2 or datetime.date.max + + # Ranges intersect if start1 <= end2 and start2 <= end1 + return start1 <= end2 and start2 <= end1 + class Meta: verbose_name_plural="Vorgaben" ordering = ['order'] diff --git a/dokumente/tests.py b/dokumente/tests.py index b0872f8..a9f7c67 100644 --- a/dokumente/tests.py +++ b/dokumente/tests.py @@ -1,11 +1,14 @@ from django.test import TestCase, Client from django.urls import reverse +from django.core.management import call_command from datetime import date, timedelta +from io import StringIO from .models import ( Dokumententyp, Person, Thema, Dokument, Vorgabe, VorgabeLangtext, VorgabeKurztext, Geltungsbereich, Einleitung, Checklistenfrage, Changelog ) +from .utils import check_vorgabe_conflicts, date_ranges_intersect, format_conflict_report from abschnitte.models import AbschnittTyp from referenzen.models import Referenz from stichworte.models import Stichwort @@ -513,3 +516,304 @@ class URLPatternsTest(TestCase): """Test that standard_history URL resolves correctly""" url = reverse('standard_history', kwargs={'nummer': 'TEST-001'}) self.assertEqual(url, '/dokumente/TEST-001/history/') + + +class VorgabeSanityCheckTest(TestCase): + """Test cases for Vorgabe sanity check functionality""" + + def setUp(self): + """Set up test data for sanity check tests""" + self.dokumententyp = Dokumententyp.objects.create( + name="Standard IT-Sicherheit", + verantwortliche_ve="SR-SUR-SEC" + ) + self.dokument = Dokument.objects.create( + nummer="R0066", + dokumententyp=self.dokumententyp, + name="IT Security Standard", + aktiv=True + ) + self.thema = Thema.objects.create(name="Organisation") + self.base_date = date(2023, 1, 1) + + # Create non-conflicting Vorgaben + self.vorgabe1 = Vorgabe.objects.create( + order=1, + nummer=1, + dokument=self.dokument, + thema=self.thema, + titel="First Vorgabe", + gueltigkeit_von=self.base_date, + gueltigkeit_bis=date(2023, 12, 31) + ) + + self.vorgabe2 = Vorgabe.objects.create( + order=2, + nummer=2, + dokument=self.dokument, + thema=self.thema, + titel="Second Vorgabe", + gueltigkeit_von=self.base_date, + gueltigkeit_bis=date(2023, 12, 31) + ) + + def test_date_ranges_intersect_no_overlap(self): + """Test date_ranges_intersect with non-overlapping ranges""" + # Range 1: 2023-01-01 to 2023-06-30 + # Range 2: 2023-07-01 to 2023-12-31 + result = date_ranges_intersect( + date(2023, 1, 1), date(2023, 6, 30), + date(2023, 7, 1), date(2023, 12, 31) + ) + self.assertFalse(result) + + def test_date_ranges_intersect_with_overlap(self): + """Test date_ranges_intersect with overlapping ranges""" + # Range 1: 2023-01-01 to 2023-06-30 + # Range 2: 2023-06-01 to 2023-12-31 (overlaps in June) + result = date_ranges_intersect( + date(2023, 1, 1), date(2023, 6, 30), + date(2023, 6, 1), date(2023, 12, 31) + ) + self.assertTrue(result) + + def test_date_ranges_intersect_with_none_end_date(self): + """Test date_ranges_intersect with None end date (open-ended)""" + # Range 1: 2023-01-01 to None (open-ended) + # Range 2: 2023-06-01 to 2023-12-31 + result = date_ranges_intersect( + date(2023, 1, 1), None, + date(2023, 6, 1), date(2023, 12, 31) + ) + self.assertTrue(result) + + def test_date_ranges_intersect_both_none_end_dates(self): + """Test date_ranges_intersect with both None end dates""" + # Both ranges are open-ended + result = date_ranges_intersect( + date(2023, 1, 1), None, + date(2023, 6, 1), None + ) + self.assertTrue(result) + + def test_date_ranges_intersect_identical_ranges(self): + """Test date_ranges_intersect with identical ranges""" + result = date_ranges_intersect( + date(2023, 1, 1), date(2023, 12, 31), + date(2023, 1, 1), date(2023, 12, 31) + ) + self.assertTrue(result) + + def test_sanity_check_vorgaben_no_conflicts(self): + """Test sanity_check_vorgaben with no conflicts""" + conflicts = Vorgabe.sanity_check_vorgaben() + self.assertEqual(len(conflicts), 0) + + def test_sanity_check_vorgaben_with_conflicts(self): + """Test sanity_check_vorgaben with conflicting Vorgaben""" + # Create a conflicting Vorgabe (same nummer, thema, dokument with overlapping dates) + conflicting_vorgabe = Vorgabe.objects.create( + order=3, + nummer=1, # Same as vorgabe1 + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe", + gueltigkeit_von=date(2023, 6, 1), # Overlaps with vorgabe1 + gueltigkeit_bis=date(2023, 8, 31) + ) + + conflicts = Vorgabe.sanity_check_vorgaben() + self.assertEqual(len(conflicts), 1) + + conflict = conflicts[0] + self.assertEqual(conflict['conflict_type'], 'date_range_intersection') + self.assertIn('R0066.O.1', conflict['message']) + self.assertIn('intersecting validity periods', conflict['message']) + self.assertEqual(conflict['vorgabe1'], self.vorgabe1) + self.assertEqual(conflict['vorgabe2'], conflicting_vorgabe) + + def test_sanity_check_vorgaben_multiple_conflicts(self): + """Test sanity_check_vorgaben with multiple conflict groups""" + # Create first conflict group + conflicting_vorgabe1 = Vorgabe.objects.create( + order=3, + nummer=1, # Same as vorgabe1 + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe 1", + gueltigkeit_von=date(2023, 6, 1), + gueltigkeit_bis=date(2023, 8, 31) + ) + + # Create second conflict group with different nummer + conflicting_vorgabe2 = Vorgabe.objects.create( + order=4, + nummer=2, # Same as vorgabe2 + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe 2", + gueltigkeit_von=date(2023, 6, 1), + gueltigkeit_bis=date(2023, 8, 31) + ) + + conflicts = Vorgabe.sanity_check_vorgaben() + self.assertEqual(len(conflicts), 2) + + # Check that we have conflicts for both nummer 1 and nummer 2 + conflict_messages = [c['message'] for c in conflicts] + self.assertTrue(any('R0066.O.1' in msg for msg in conflict_messages)) + self.assertTrue(any('R0066.O.2' in msg for msg in conflict_messages)) + + def test_find_conflicts_no_conflicts(self): + """Test find_conflicts method on Vorgabe with no conflicts""" + conflicts = self.vorgabe1.find_conflicts() + self.assertEqual(len(conflicts), 0) + + def test_find_conflicts_with_conflicts(self): + """Test find_conflicts method on Vorgabe with conflicts""" + # Create a conflicting Vorgabe + conflicting_vorgabe = Vorgabe.objects.create( + order=3, + nummer=1, # Same as vorgabe1 + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe", + gueltigkeit_von=date(2023, 6, 1), # Overlaps + gueltigkeit_bis=date(2023, 8, 31) + ) + + conflicts = self.vorgabe1.find_conflicts() + self.assertEqual(len(conflicts), 1) + conflict = conflicts[0] + self.assertEqual(conflict['vorgabe1'], self.vorgabe1) + self.assertEqual(conflict['vorgabe2'], conflicting_vorgabe) + + def test_vorgabe_clean_no_conflicts(self): + """Test Vorgabe.clean() with no conflicts""" + try: + self.vorgabe1.clean() + except Exception as e: + self.fail(f"clean() raised {e} unexpectedly!") + + def test_vorgabe_clean_with_conflicts(self): + """Test Vorgabe.clean() with conflicts raises ValidationError""" + # Create a conflicting Vorgabe + conflicting_vorgabe = Vorgabe( + order=3, + nummer=1, # Same as vorgabe1 + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe", + gueltigkeit_von=date(2023, 6, 1), # Overlaps + gueltigkeit_bis=date(2023, 8, 31) + ) + + with self.assertRaises(Exception) as context: + conflicting_vorgabe.clean() + + self.assertIn('conflicts with existing', str(context.exception)) + self.assertIn('overlapping validity periods', str(context.exception)) + + def test_check_vorgabe_conflicts_utility(self): + """Test check_vorgabe_conflicts utility function""" + # Initially no conflicts + conflicts = check_vorgabe_conflicts() + self.assertEqual(len(conflicts), 0) + + # Create a conflicting Vorgabe + conflicting_vorgabe = Vorgabe.objects.create( + order=3, + nummer=1, # Same as vorgabe1 + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe", + gueltigkeit_von=date(2023, 6, 1), # Overlaps + gueltigkeit_bis=date(2023, 8, 31) + ) + + conflicts = check_vorgabe_conflicts() + self.assertEqual(len(conflicts), 1) + + def test_format_conflict_report_no_conflicts(self): + """Test format_conflict_report with no conflicts""" + report = format_conflict_report([]) + self.assertEqual(report, "✓ No conflicts found in Vorgaben") + + def test_format_conflict_report_with_conflicts(self): + """Test format_conflict_report with conflicts""" + # Create a conflicting Vorgabe + conflicting_vorgabe = Vorgabe.objects.create( + order=3, + nummer=1, # Same as vorgabe1 + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe", + gueltigkeit_von=date(2023, 6, 1), # Overlaps + gueltigkeit_bis=date(2023, 8, 31) + ) + + conflicts = check_vorgabe_conflicts() + report = format_conflict_report(conflicts) + + self.assertIn("Found 1 conflicts:", report) + self.assertIn("R0066.O.1", report) + self.assertIn("intersecting validity periods", report) + + +class SanityCheckManagementCommandTest(TestCase): + """Test cases for sanity_check_vorgaben management command""" + + def setUp(self): + """Set up test data for management command tests""" + self.dokumententyp = Dokumententyp.objects.create( + name="Standard IT-Sicherheit", + verantwortliche_ve="SR-SUR-SEC" + ) + self.dokument = Dokument.objects.create( + nummer="R0066", + dokumententyp=self.dokumententyp, + name="IT Security Standard", + aktiv=True + ) + self.thema = Thema.objects.create(name="Organisation") + + def test_sanity_check_command_no_conflicts(self): + """Test management command with no conflicts""" + out = StringIO() + call_command('sanity_check_vorgaben', stdout=out) + + output = out.getvalue() + self.assertIn("Starting Vorgaben sanity check...", output) + self.assertIn("✓ No conflicts found in Vorgaben", output) + + def test_sanity_check_command_with_conflicts(self): + """Test management command with conflicts""" + # Create conflicting Vorgaben + Vorgabe.objects.create( + order=1, + nummer=1, + dokument=self.dokument, + thema=self.thema, + titel="First Vorgabe", + gueltigkeit_von=date(2023, 1, 1), + gueltigkeit_bis=date(2023, 12, 31) + ) + + Vorgabe.objects.create( + order=2, + nummer=1, # Same nummer, thema, dokument + dokument=self.dokument, + thema=self.thema, + titel="Conflicting Vorgabe", + gueltigkeit_von=date(2023, 6, 1), # Overlaps + gueltigkeit_bis=date(2023, 8, 31) + ) + + out = StringIO() + call_command('sanity_check_vorgaben', stdout=out) + + output = out.getvalue() + self.assertIn("Starting Vorgaben sanity check...", output) + self.assertIn("Found 1 conflicts:", output) + self.assertIn("R0066.O.1", output) + self.assertIn("intersecting validity periods", output) diff --git a/dokumente/utils.py b/dokumente/utils.py new file mode 100644 index 0000000..a0c6930 --- /dev/null +++ b/dokumente/utils.py @@ -0,0 +1,123 @@ +""" +Utility functions for Vorgaben sanity checking +""" +import datetime +from django.db.models import Count +from itertools import combinations +from dokumente.models import Vorgabe + + +def check_vorgabe_conflicts(): + """ + Check for conflicts in Vorgaben. + + Main rule: If there are two Vorgaben with the same number, Thema and Dokument, + their valid_from and valid_to date ranges shouldn't intersect. + + Returns: + list: List of conflict dictionaries + """ + conflicts = [] + + # Find Vorgaben with same dokument, thema, and nummer + duplicate_groups = ( + Vorgabe.objects.values('dokument', 'thema', 'nummer') + .annotate(count=Count('id')) + .filter(count__gt=1) + ) + + for group in duplicate_groups: + # Get all Vorgaben in this group + vorgaben = Vorgabe.objects.filter( + dokument=group['dokument'], + thema=group['thema'], + nummer=group['nummer'] + ) + + # Check all pairs for date range intersections + for vorgabe1, vorgabe2 in combinations(vorgaben, 2): + if date_ranges_intersect( + vorgabe1.gueltigkeit_von, vorgabe1.gueltigkeit_bis, + vorgabe2.gueltigkeit_von, vorgabe2.gueltigkeit_bis + ): + conflicts.append({ + 'vorgabe1': vorgabe1, + 'vorgabe2': vorgabe2, + 'conflict_type': 'date_range_intersection', + 'message': f"Vorgaben {vorgabe1.Vorgabennummer()} and {vorgabe2.Vorgabennummer()} " + f"have intersecting validity periods" + }) + + return conflicts + + +def date_ranges_intersect(start1, end1, start2, end2): + """ + Check if two date ranges intersect. + None end date means open-ended range. + + Args: + start1, start2: Start dates + end1, end2: End dates (can be None for open-ended) + + Returns: + bool: True if ranges intersect + """ + # If either start date is None, treat it as invalid case + if not start1 or not start2: + return False + + # If end date is None, treat it as far future + end1 = end1 or datetime.date.max + end2 = end2 or datetime.date.max + + # Ranges intersect if start1 <= end2 and start2 <= end1 + return start1 <= end2 and start2 <= end1 + + +def format_conflict_report(conflicts, verbose=False): + """ + Format conflicts into a readable report. + + Args: + conflicts: List of conflict dictionaries + verbose: Whether to show detailed information + + Returns: + str: Formatted report + """ + if not conflicts: + return "✓ No conflicts found in Vorgaben" + + lines = [f"Found {len(conflicts)} conflicts:"] + + for i, conflict in enumerate(conflicts, 1): + lines.append(f"\n{i}. {conflict['message']}") + + if verbose: + v1 = conflict['vorgabe1'] + v2 = conflict['vorgabe2'] + + lines.append(f" Vorgabe 1: {v1.Vorgabennummer()}") + lines.append(f" Valid from: {v1.gueltigkeit_von} to {v1.gueltigkeit_bis or 'unlimited'}") + lines.append(f" Title: {v1.titel}") + + lines.append(f" Vorgabe 2: {v2.Vorgabennummer()}") + lines.append(f" Valid from: {v2.gueltigkeit_von} to {v2.gueltigkeit_bis or 'unlimited'}") + lines.append(f" Title: {v2.titel}") + + # Show the overlapping period + v1 = conflict['vorgabe1'] + v2 = conflict['vorgabe2'] + overlap_start = max(v1.gueltigkeit_von, v2.gueltigkeit_von) + overlap_end = min( + v1.gueltigkeit_bis or datetime.date.max, + v2.gueltigkeit_bis or datetime.date.max + ) + + if overlap_end != datetime.date.max: + lines.append(f" Overlap: {overlap_start} to {overlap_end}") + else: + lines.append(f" Overlap starts: {overlap_start} (no end)") + + return "\n".join(lines) \ No newline at end of file diff --git a/static/admin/css/vorgabe_border.css b/static/admin/css/vorgabe_border.css index 22168ff..1ad3e8c 100644 --- a/static/admin/css/vorgabe_border.css +++ b/static/admin/css/vorgabe_border.css @@ -5,7 +5,6 @@ border-radius: 8px; padding: 15px; margin-bottom: 50px; - background-color: #f9f9f9; box-shadow: 0 2px 5px rgba(0,0,0,0.1); } @@ -38,4 +37,4 @@ tbody.djn-dynamic-form-dokumente-vorgabe td.original p { margin-top: 10px; padding-left: 10px; border-left: 2px dashed #ccc; -} \ No newline at end of file +} diff --git a/static/admin/js/vorgabe_collapse.js b/static/admin/js/vorgabe_collapse.js index 1bd1341..68abffe 100644 --- a/static/admin/js/vorgabe_collapse.js +++ b/static/admin/js/vorgabe_collapse.js @@ -1,21 +1,58 @@ window.addEventListener('load', function () { setTimeout(() => { - const vorgabenBlocks = document.querySelectorAll('.djn-dynamic-form-Standards-vorgabe'); - console.log("Found", vorgabenBlocks.length, "Vorgaben blocks"); + // Try different selectors for nested admin vorgabe elements + const selectors = [ + '.djn-dynamic-form-dokumente-vorgabe', + '.djn-dynamic-form-Standards-vorgabe', + '.inline-related[data-inline-type="stacked"]', + '.nested-inline' + ]; + + let vorgabenBlocks = []; + for (const selector of selectors) { + vorgabenBlocks = document.querySelectorAll(selector); + if (vorgabenBlocks.length > 0) { + console.log("Found", vorgabenBlocks.length, "Vorgaben blocks with selector:", selector); + break; + } + } + + if (vorgabenBlocks.length === 0) { + console.log("No Vorgaben blocks found, trying fallback..."); + // Fallback: look for any inline with vorgabe in the class + vorgabenBlocks = document.querySelectorAll('[class*="vorgabe"]'); + } vorgabenBlocks.forEach((block, index) => { - const header = document.createElement('div'); - header.className = 'vorgabe-toggle-header'; - header.innerHTML = `▼ Vorgabe ${index + 1}`; - header.style.cursor = 'pointer'; - - block.parentNode.insertBefore(header, block); - - header.addEventListener('click', () => { - const isHidden = block.style.display === 'none'; - block.style.display = isHidden ? '' : 'none'; - header.innerHTML = `${isHidden ? '▼' : '▶'} Vorgabe ${index + 1}`; - }); + // Find the existing title/header within the vorgabe block + const existingHeader = block.querySelector('h3, .inline-label, .module h2, .djn-inline-header'); + + if (existingHeader) { + // Make the existing header clickable for collapse/expand + existingHeader.style.cursor = 'pointer'; + existingHeader.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Find all content to collapse - everything except the header itself + const allChildren = Array.from(block.children); + const contentElements = allChildren.filter(child => child !== existingHeader && !child.contains(existingHeader)); + + contentElements.forEach(element => { + const isHidden = element.style.display === 'none'; + element.style.display = isHidden ? '' : 'none'; + }); + + // Update the header text to show collapse state + const originalText = existingHeader.textContent.replace(/[▼▶]\s*/, ''); + const anyHidden = contentElements.some(el => el.style.display === 'none'); + existingHeader.innerHTML = `${anyHidden ? '▶' : '▼'} ${originalText}`; + }); + + // Add initial collapse indicator + const originalText = existingHeader.textContent; + existingHeader.innerHTML = `▼ ${originalText}`; + } }); - }, 500); // wait 500ms to allow nested inlines to render + }, 1000); // wait longer to allow nested inlines to render }); diff --git a/test_sanity_check.py b/test_sanity_check.py new file mode 100644 index 0000000..72df591 --- /dev/null +++ b/test_sanity_check.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +""" +Simple script to test Vorgaben sanity checking +""" +import os +import sys +import django + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'VorgabenUI.settings') +django.setup() + +from dokumente.utils import check_vorgabe_conflicts, format_conflict_report + + +def main(): + print("Running Vorgaben sanity check...") + print("=" * 50) + + # Check for conflicts + conflicts = check_vorgabe_conflicts() + + # Generate and display report + report = format_conflict_report(conflicts, verbose=True) + print(report) + + print("=" * 50) + + if conflicts: + print(f"\n⚠️ Found {len(conflicts)} conflicts that need attention!") + sys.exit(1) + else: + print("✅ All Vorgaben are valid!") + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file