Add Vorgaben sanity check functionality
Implement comprehensive validation system to detect conflicting Vorgaben with overlapping validity periods. Features: - Static method Vorgabe.sanity_check_vorgaben() for global conflict detection - Instance method Vorgabe.find_conflicts() for individual conflict checking - Model validation via Vorgabe.clean() to prevent conflicting data - Utility functions for date range intersection and conflict reporting - Django management command 'sanity_check_vorgaben' for manual checks - Comprehensive test suite with 17 new tests covering all functionality Validation logic ensures Vorgaben with same dokument, thema, and nummer cannot have overlapping gueltigkeit_von/gueltigkeit_bis date ranges. Handles open-ended ranges (None end dates) and provides clear error messages. Files added/modified: - dokumente/models.py: Added sanity check methods and validation - dokumente/utils.py: New utility functions for conflict detection - dokumente/management/commands/sanity_check_vorgaben.py: New management command - dokumente/tests.py: Added comprehensive test coverage - test_sanity_check.py: Standalone test script All tests pass (56/56) with no regressions.
This commit is contained in:
123
dokumente/utils.py
Normal file
123
dokumente/utils.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user