Compare commits

..

33 Commits

Author SHA1 Message Date
28a1bb4b62 Translated 'rogue' English error message 2025-11-04 11:21:04 +01:00
898e9b8163 Merge branch 'feature/sanitychecks' into development 2025-11-04 09:07:37 +01:00
48bf8526b9 Deploy 941 - new database 2025-11-04 09:06:04 +01:00
7e4d2fa29b Changed edge case in date validation for Vorgaben 2025-11-03 13:21:47 +01:00
779604750e 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.
2025-11-03 12:55:56 +01:00
aca9a2f307 Removed "Ändern" and "Löschen"-Links 2025-11-03 12:36:39 +01:00
d14d9eba4c Deploy 940 2025-11-01 01:29:13 +01:00
081ea4de1c background of Vorgaben changed - looks better in dark mode. 2025-11-01 01:09:40 +01:00
a075811173 Collapsing and drag/drop implemented 2025-11-01 00:34:21 +01:00
d4143da9fc Horizontal fieldsets OK 2025-11-01 00:21:13 +01:00
b0c9b89e94 Borders work, collapsing doesn't yet 2025-11-01 00:18:29 +01:00
Adrian A. Baumann
94363d49ce Deploy 939 2025-10-31 12:35:26 +01:00
Adrian A. Baumann
8bca1bb3c7 Tabular view for Vorgaben added 2025-10-31 11:43:34 +01:00
Adrian A. Baumann
1ce8eb15c0 Merge branch 'feature/textabschnitte-comprehensive-tests' into development 2025-10-29 14:26:23 +01:00
Adrian A. Baumann
4d2ffeea27 .gitignore extended by npm stuff 2025-10-29 14:11:53 +01:00
Adrian A. Baumann
8860947d38 Add comprehensive tests for Textabschnitte app
- Add 41 comprehensive test cases covering all functionality
- Test AbschnittTyp model creation and validation
- Test Textabschnitt abstract model through VorgabeLangtext
- Test all rendering types: text, lists, tables, code, diagrams
- Test markdown rendering with footnotes and formatting
- Test table conversion from markdown to Bootstrap HTML
- Test diagram caching with mocked external service calls
- Test diagram error handling and custom options
- Test clear_diagram_cache management command
- Test integration with dokumente models
- All tests passing (41/41)
2025-10-29 14:09:02 +01:00
Adrian A. Baumann
6df72c95cb Tests for documents fixed (Vorgabe-Order added) 2025-10-29 13:47:16 +01:00
2afada0bce Date 'bis None' changed to 'bis auf weiteres' 2025-10-29 13:30:46 +01:00
Adrian A. Baumann
a42a65b40f Make Vorgaben draggable; Deploy 938 2025-10-28 16:19:37 +01:00
5609a735f4 Deploy 937 2025-10-28 13:41:13 +01:00
6654779e67 Corrections on Dokumente-Admin; Homepage now only shows active documents 2025-10-28 13:36:26 +01:00
7befde104d Added 'aktiv' to document tests 2025-10-27 21:24:23 +01:00
96819a7427 Merge branch 'feature/dokumente-unit-tests' into development 2025-10-27 21:18:53 +01:00
a437af554b Deploy 936 2025-10-27 20:53:13 +01:00
650fe0a87b added 'aktiv' to dokument (so people can play around with standards) 2025-10-27 20:49:22 +01:00
Adrian A. Baumann
ddf035c50f Deploy 935 2025-10-27 16:57:35 +01:00
Adrian A. Baumann
886baa163e Increase whitespace between Vorgabe boxes
Increased margin-bottom from 30px to 50px for better visual separation between Vorgaben.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:47:19 +01:00
Adrian A. Baumann
1146506ca2 Fix selector for tabular Vorgabe identifiers with tbody target
Changed selector to target tbody.djn-dynamic-form-dokumente-vorgabe and added !important to override existing styles.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:46:05 +01:00
Adrian A. Baumann
9610024739 Make Vorgabe identifier text in tabular view prominent
Styled the td.original cell containing Vorgabe identifiers (e.g., "R0066.O.3: Dateninhaber"):
- Font size: 16px
- Font weight: 700 (bold)
- Blue color matching border (#2c5aa0)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:44:30 +01:00
Adrian A. Baumann
c8755e4339 Make Vorgabe titles bigger and more prominent
- Increased font size to 18px with bold weight (700)
- Blue color (#2c5aa0) matching the border
- Light blue gradient background
- Bottom border separator
- Extends full width with negative margins

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:42:34 +01:00
Adrian A. Baumann
0bc1fe7413 Add prominent border boxes around each Vorgabe
- 3px solid blue border (#2c5aa0)
- Increased margin between Vorgaben (30px)
- Added subtle box shadow
- Support both Standards and dokumente class names

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:41:14 +01:00
Adrian A. Baumann
8ce761c248 Deploy 934 2025-10-27 14:00:21 +01:00
Adrian A. Baumann
39a2021cc3 Attempt at improving reference choice in documents 2025-10-27 13:50:14 +01:00
22 changed files with 1890 additions and 72 deletions

4
.gitignore vendored
View File

@@ -10,6 +10,8 @@ keys/
.idea/
*.kate-swp
node_modules/
package-lock.json
package.json
# Diagram cache directory
media/diagram_cache/

View File

@@ -1,3 +1,820 @@
from django.test import TestCase
from django.test import TestCase, TransactionTestCase
from django.core.management import call_command
from django.conf import settings
from django.core.files.storage import default_storage
from unittest.mock import patch, Mock, MagicMock
from io import StringIO
import os
import hashlib
import tempfile
import shutil
# Create your tests here.
from .models import AbschnittTyp, Textabschnitt
from .utils import render_textabschnitte, md_table_to_html
from diagramm_proxy.diagram_cache import (
get_cached_diagram, compute_hash, get_cache_path, clear_cache
)
class AbschnittTypModelTest(TestCase):
"""Test cases for AbschnittTyp model"""
def setUp(self):
"""Set up test data"""
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="text"
)
def test_abschnitttyp_creation(self):
"""Test that AbschnittTyp is created correctly"""
self.assertEqual(self.abschnitttyp.abschnitttyp, "text")
def test_abschnitttyp_str(self):
"""Test string representation of AbschnittTyp"""
self.assertEqual(str(self.abschnitttyp), "text")
def test_abschnitttyp_verbose_name_plural(self):
"""Test verbose name plural"""
self.assertEqual(
AbschnittTyp._meta.verbose_name_plural,
"Abschnitttypen"
)
def test_abschnitttyp_primary_key(self):
"""Test that abschnitttyp field is the primary key"""
pk_field = AbschnittTyp._meta.pk
self.assertEqual(pk_field.name, 'abschnitttyp')
def test_create_multiple_abschnitttypen(self):
"""Test creating multiple AbschnittTyp objects"""
types = ['liste ungeordnet', 'liste geordnet', 'tabelle', 'diagramm', 'code']
for typ in types:
AbschnittTyp.objects.create(abschnitttyp=typ)
self.assertEqual(AbschnittTyp.objects.count(), 6) # Including setUp type
class TextabschnittModelTest(TestCase):
"""Test cases for Textabschnitt abstract model using VorgabeLangtext"""
def setUp(self):
"""Set up test data"""
from dokumente.models import Dokumententyp, Dokument, Vorgabe, VorgabeLangtext, Thema
from datetime import date
self.typ_text = AbschnittTyp.objects.create(abschnitttyp="text")
self.typ_code = AbschnittTyp.objects.create(abschnitttyp="code")
# Create required dokumente objects
self.dokumententyp = Dokumententyp.objects.create(
name="Test Type", verantwortliche_ve="TEST"
)
self.dokument = Dokument.objects.create(
nummer="TEST-001",
name="Test Doc",
dokumententyp=self.dokumententyp,
aktiv=True
)
self.thema = Thema.objects.create(name="Test Thema")
self.vorgabe = Vorgabe.objects.create(
dokument=self.dokument,
nummer=1,
order=1,
thema=self.thema,
titel="Test Vorgabe",
gueltigkeit_von=date.today()
)
def test_textabschnitt_creation(self):
"""Test that Textabschnitt can be instantiated via concrete model"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="Test content",
order=1
)
self.assertEqual(abschnitt.abschnitttyp, self.typ_text)
self.assertEqual(abschnitt.inhalt, "Test content")
self.assertEqual(abschnitt.order, 1)
def test_textabschnitt_default_order(self):
"""Test that order defaults to 0"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="Test"
)
self.assertEqual(abschnitt.order, 0)
def test_textabschnitt_blank_fields(self):
"""Test that abschnitttyp and inhalt can be blank/null"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe
)
self.assertIsNone(abschnitt.abschnitttyp)
self.assertIsNone(abschnitt.inhalt)
def test_textabschnitt_ordering(self):
"""Test that Textabschnitte can be ordered"""
from dokumente.models import VorgabeLangtext
abschnitt1 = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="First",
order=2
)
abschnitt2 = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="Second",
order=1
)
abschnitt3 = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="Third",
order=3
)
ordered = VorgabeLangtext.objects.filter(abschnitt=self.vorgabe).order_by('order')
self.assertEqual(list(ordered), [abschnitt2, abschnitt1, abschnitt3])
def test_textabschnitt_foreign_key_protection(self):
"""Test that AbschnittTyp is protected from deletion"""
from dokumente.models import VorgabeLangtext
from django.db.models import ProtectedError
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="Test"
)
# Try to delete the AbschnittTyp
with self.assertRaises(ProtectedError):
self.typ_text.delete()
class RenderTextabschnitteTest(TestCase):
"""Test cases for render_textabschnitte function"""
def setUp(self):
"""Set up test data"""
from dokumente.models import Dokumententyp, Dokument, Vorgabe, VorgabeLangtext, Thema
from datetime import date
self.typ_text = AbschnittTyp.objects.create(abschnitttyp="text")
self.typ_unordered = AbschnittTyp.objects.create(abschnitttyp="liste ungeordnet")
self.typ_ordered = AbschnittTyp.objects.create(abschnitttyp="liste geordnet")
self.typ_table = AbschnittTyp.objects.create(abschnitttyp="tabelle")
self.typ_code = AbschnittTyp.objects.create(abschnitttyp="code")
self.typ_diagram = AbschnittTyp.objects.create(abschnitttyp="diagramm")
# Create required dokumente objects
self.dokumententyp = Dokumententyp.objects.create(
name="Test Type", verantwortliche_ve="TEST"
)
self.dokument = Dokument.objects.create(
nummer="TEST-001",
name="Test Doc",
dokumententyp=self.dokumententyp,
aktiv=True
)
self.thema = Thema.objects.create(name="Test Thema")
self.vorgabe = Vorgabe.objects.create(
dokument=self.dokument,
nummer=1,
order=1,
thema=self.thema,
titel="Test Vorgabe",
gueltigkeit_von=date.today()
)
def test_render_empty_queryset(self):
"""Test rendering an empty queryset"""
from dokumente.models import VorgabeLangtext
result = render_textabschnitte(VorgabeLangtext.objects.none())
self.assertEqual(result, [])
def test_render_text_markdown(self):
"""Test rendering plain text with markdown"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="# Heading\n\nThis is **bold** text.",
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "text")
self.assertIn("<h1>Heading</h1>", html)
self.assertIn("<strong>bold</strong>", html)
def test_render_text_with_footnotes(self):
"""Test rendering text with footnotes"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="This is text[^1].\n\n[^1]: This is a footnote.",
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertIn("footnote", html.lower())
def test_render_unordered_list(self):
"""Test rendering unordered list"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_unordered,
inhalt="Item 1\nItem 2\nItem 3",
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertEqual(typ, "liste ungeordnet")
self.assertIn("<ul>", html)
self.assertIn("<li>Item 1</li>", html)
self.assertIn("<li>Item 2</li>", html)
self.assertIn("<li>Item 3</li>", html)
def test_render_ordered_list(self):
"""Test rendering ordered list"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_ordered,
inhalt="First item\nSecond item\nThird item",
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertEqual(typ, "liste geordnet")
self.assertIn("<ol>", html)
self.assertIn("<li>First item</li>", html)
self.assertIn("<li>Second item</li>", html)
self.assertIn("<li>Third item</li>", html)
def test_render_table(self):
"""Test rendering table"""
from dokumente.models import VorgabeLangtext
table_content = """| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |"""
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_table,
inhalt=table_content,
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertEqual(typ, "tabelle")
self.assertIn('<table class="table table-bordered table-hover">', html)
self.assertIn("<thead>", html)
self.assertIn("<th>Header 1</th>", html)
self.assertIn("<th>Header 2</th>", html)
self.assertIn("<tbody>", html)
self.assertIn("<td>Cell 1</td>", html)
self.assertIn("<td>Cell 2</td>", html)
def test_render_code_block(self):
"""Test rendering code block"""
from dokumente.models import VorgabeLangtext
code_content = "def hello():\n print('Hello, World!')"
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_code,
inhalt=code_content,
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertEqual(typ, "code")
self.assertIn("<pre><code>", html)
self.assertIn("</code></pre>", html)
self.assertIn("hello", html)
@patch('abschnitte.utils.get_cached_diagram')
def test_render_diagram_success(self, mock_get_cached):
"""Test rendering diagram with successful caching"""
from dokumente.models import VorgabeLangtext
mock_get_cached.return_value = "diagram_cache/plantuml/abc123.svg"
diagram_content = """plantuml
@startuml
Alice -> Bob: Hello
@enduml"""
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_diagram,
inhalt=diagram_content,
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertEqual(typ, "diagramm")
self.assertIn('<img', html)
self.assertIn('width="100%"', html)
self.assertIn('diagram_cache/plantuml/abc123.svg', html)
# Verify get_cached_diagram was called correctly
mock_get_cached.assert_called_once()
args = mock_get_cached.call_args[0]
self.assertEqual(args[0], "plantuml")
self.assertIn("Alice -> Bob", args[1])
@patch('abschnitte.utils.get_cached_diagram')
def test_render_diagram_with_options(self, mock_get_cached):
"""Test rendering diagram with custom options"""
from dokumente.models import VorgabeLangtext
mock_get_cached.return_value = "diagram_cache/mermaid/xyz789.svg"
diagram_content = """mermaid
option: width="50%" height="300px"
graph TD
A-->B"""
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_diagram,
inhalt=diagram_content,
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertIn('width="50%"', html)
self.assertIn('height="300px"', html)
@patch('abschnitte.utils.get_cached_diagram')
def test_render_diagram_error(self, mock_get_cached):
"""Test rendering diagram when caching fails"""
from dokumente.models import VorgabeLangtext
mock_get_cached.side_effect = Exception("Connection error")
diagram_content = """plantuml
@startuml
A -> B
@enduml"""
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_diagram,
inhalt=diagram_content,
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertIn("Error generating diagram", html)
self.assertIn("Connection error", html)
self.assertIn('class="text-danger"', html)
def test_render_multiple_abschnitte(self):
"""Test rendering multiple Textabschnitte in order"""
from dokumente.models import VorgabeLangtext
abschnitt1 = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="First section",
order=1
)
abschnitt2 = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_unordered,
inhalt="Item 1\nItem 2",
order=2
)
abschnitt3 = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_code,
inhalt="print('hello')",
order=3
)
result = render_textabschnitte(
VorgabeLangtext.objects.filter(abschnitt=self.vorgabe).order_by('order')
)
self.assertEqual(len(result), 3)
self.assertEqual(result[0][0], "text")
self.assertEqual(result[1][0], "liste ungeordnet")
self.assertEqual(result[2][0], "code")
def test_render_abschnitt_without_type(self):
"""Test rendering Textabschnitt without AbschnittTyp"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=None,
inhalt="Content without type",
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
typ, html = result[0]
self.assertEqual(typ, '')
self.assertIn("Content without type", html)
def test_render_abschnitt_with_empty_content(self):
"""Test rendering Textabschnitt with empty content"""
from dokumente.models import VorgabeLangtext
abschnitt = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt=None,
order=1
)
result = render_textabschnitte(VorgabeLangtext.objects.filter(abschnitt=self.vorgabe))
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "text")
class MdTableToHtmlTest(TestCase):
"""Test cases for md_table_to_html function"""
def test_simple_table(self):
"""Test converting a simple markdown table to HTML"""
md = """| Name | Age |
|------|-----|
| John | 30 |
| Jane | 25 |"""
html = md_table_to_html(md)
self.assertIn('<table class="table table-bordered table-hover">', html)
self.assertIn("<thead>", html)
self.assertIn("<th>Name</th>", html)
self.assertIn("<th>Age</th>", html)
self.assertIn("<tbody>", html)
self.assertIn("<td>John</td>", html)
self.assertIn("<td>30</td>", html)
self.assertIn("<td>Jane</td>", html)
self.assertIn("<td>25</td>", html)
def test_table_with_multiple_rows(self):
"""Test table with multiple rows"""
md = """| A | B | C |
|---|---|---|
| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 7 | 8 | 9 |"""
html = md_table_to_html(md)
self.assertEqual(html.count("<tr>"), 4) # 1 header + 3 body rows
self.assertEqual(html.count("<td>"), 9) # 3x3 cells
self.assertEqual(html.count("<th>"), 3) # 3 headers
def test_table_with_spaces(self):
"""Test table with extra spaces"""
md = """ | Header 1 | Header 2 |
| --------- | ---------- |
| Value 1 | Value 2 | """
html = md_table_to_html(md)
self.assertIn("<th>Header 1</th>", html)
self.assertIn("<th>Header 2</th>", html)
self.assertIn("<td>Value 1</td>", html)
self.assertIn("<td>Value 2</td>", html)
def test_table_with_empty_cells(self):
"""Test table with empty cells"""
md = """| Col1 | Col2 | Col3 |
|------|------|------|
| A | | C |
| | B | |"""
html = md_table_to_html(md)
self.assertIn("<td>A</td>", html)
self.assertIn("<td></td>", html)
self.assertIn("<td>C</td>", html)
self.assertIn("<td>B</td>", html)
def test_table_insufficient_lines(self):
"""Test that ValueError is raised for insufficient lines"""
md = """| Header |"""
with self.assertRaises(ValueError) as context:
md_table_to_html(md)
self.assertIn("at least header + separator", str(context.exception))
def test_table_empty_string(self):
"""Test that ValueError is raised for empty string"""
with self.assertRaises(ValueError):
md_table_to_html("")
def test_table_only_whitespace(self):
"""Test that ValueError is raised for only whitespace"""
with self.assertRaises(ValueError):
md_table_to_html(" \n \n ")
class DiagramCacheTest(TestCase):
"""Test cases for diagram caching functionality"""
def setUp(self):
"""Set up test environment"""
# Create a temporary directory for testing
self.test_media_root = tempfile.mkdtemp()
self.original_media_root = settings.MEDIA_ROOT
settings.MEDIA_ROOT = self.test_media_root
def tearDown(self):
"""Clean up test environment"""
# Restore original settings
settings.MEDIA_ROOT = self.original_media_root
# Remove test directory
if os.path.exists(self.test_media_root):
shutil.rmtree(self.test_media_root)
def test_compute_hash(self):
"""Test that compute_hash generates consistent SHA256 hashes"""
content1 = "test content"
content2 = "test content"
content3 = "different content"
hash1 = compute_hash(content1)
hash2 = compute_hash(content2)
hash3 = compute_hash(content3)
# Same content should produce same hash
self.assertEqual(hash1, hash2)
# Different content should produce different hash
self.assertNotEqual(hash1, hash3)
# Hash should be 64 characters (SHA256 hex)
self.assertEqual(len(hash1), 64)
def test_get_cache_path(self):
"""Test that get_cache_path generates correct paths"""
diagram_type = "plantuml"
content_hash = "abc123"
path = get_cache_path(diagram_type, content_hash)
self.assertIn("diagram_cache", path)
self.assertIn("plantuml", path)
self.assertIn("abc123.svg", path)
@patch('diagramm_proxy.diagram_cache.requests.post')
@patch('diagramm_proxy.diagram_cache.default_storage')
def test_get_cached_diagram_miss(self, mock_storage, mock_post):
"""Test diagram generation on cache miss"""
# Setup mocks
mock_storage.exists.return_value = False
mock_storage.path.return_value = os.path.join(
self.test_media_root, 'diagram_cache/plantuml/test.svg'
)
mock_response = Mock()
mock_response.content = b'<svg>test</svg>'
mock_post.return_value = mock_response
diagram_content = "@startuml\nA -> B\n@enduml"
# Call function
result = get_cached_diagram("plantuml", diagram_content)
# Verify POST request was made
mock_post.assert_called_once()
call_args = mock_post.call_args
# Check URL in positional args (first argument)
self.assertIn("plantuml/svg", call_args[0][0])
# Verify storage.save was called
mock_storage.save.assert_called_once()
@patch('diagramm_proxy.diagram_cache.default_storage')
def test_get_cached_diagram_hit(self, mock_storage):
"""Test diagram retrieval on cache hit"""
# Setup mock - diagram exists in cache
mock_storage.exists.return_value = True
diagram_content = "@startuml\nA -> B\n@enduml"
# Call function
result = get_cached_diagram("plantuml", diagram_content)
# Verify no save was attempted (cache hit)
mock_storage.save.assert_not_called()
# Verify result contains expected path elements
self.assertIn("diagram_cache", result)
self.assertIn("plantuml", result)
self.assertIn(".svg", result)
@patch('diagramm_proxy.diagram_cache.requests.post')
@patch('diagramm_proxy.diagram_cache.default_storage')
def test_get_cached_diagram_request_error(self, mock_storage, mock_post):
"""Test that request errors are properly raised"""
import requests
mock_storage.exists.return_value = False
mock_storage.path.return_value = os.path.join(
self.test_media_root, 'diagram_cache/plantuml/test.svg'
)
mock_post.side_effect = requests.RequestException("Connection error")
with self.assertRaises(requests.RequestException):
get_cached_diagram("plantuml", "@startuml\nA -> B\n@enduml")
@patch('diagramm_proxy.diagram_cache.default_storage')
def test_clear_cache_specific_type(self, mock_storage):
"""Test clearing cache for specific diagram type"""
# Create real test cache structure for this test
cache_dir = os.path.join(self.test_media_root, 'diagram_cache', 'plantuml')
os.makedirs(cache_dir, exist_ok=True)
# Create test files
test_file1 = os.path.join(cache_dir, 'test1.svg')
test_file2 = os.path.join(cache_dir, 'test2.svg')
open(test_file1, 'w').close()
open(test_file2, 'w').close()
# Mock storage methods
mock_storage.exists.return_value = True
mock_storage.path.return_value = cache_dir
# Clear cache
clear_cache('plantuml')
# Verify files are deleted
self.assertFalse(os.path.exists(test_file1))
self.assertFalse(os.path.exists(test_file2))
@patch('diagramm_proxy.diagram_cache.default_storage')
def test_clear_cache_all_types(self, mock_storage):
"""Test clearing cache for all diagram types"""
# Create real test cache structure with multiple types
cache_root = os.path.join(self.test_media_root, 'diagram_cache')
for diagram_type in ['plantuml', 'mermaid', 'graphviz']:
cache_dir = os.path.join(cache_root, diagram_type)
os.makedirs(cache_dir, exist_ok=True)
test_file = os.path.join(cache_dir, 'test.svg')
open(test_file, 'w').close()
# Mock storage methods
mock_storage.exists.return_value = True
mock_storage.path.return_value = cache_root
# Clear all cache
clear_cache()
# Verify all files are deleted
for diagram_type in ['plantuml', 'mermaid', 'graphviz']:
test_file = os.path.join(cache_root, diagram_type, 'test.svg')
self.assertFalse(os.path.exists(test_file))
class ClearDiagramCacheCommandTest(TestCase):
"""Test cases for clear_diagram_cache management command"""
def setUp(self):
"""Set up test environment"""
self.test_media_root = tempfile.mkdtemp()
self.original_media_root = settings.MEDIA_ROOT
settings.MEDIA_ROOT = self.test_media_root
def tearDown(self):
"""Clean up test environment"""
settings.MEDIA_ROOT = self.original_media_root
if os.path.exists(self.test_media_root):
shutil.rmtree(self.test_media_root)
def test_command_without_type(self):
"""Test running command without specifying type"""
# Create test cache
cache_dir = os.path.join(self.test_media_root, 'diagram_cache', 'plantuml')
os.makedirs(cache_dir, exist_ok=True)
test_file = os.path.join(cache_dir, 'test.svg')
open(test_file, 'w').close()
# Run command
out = StringIO()
call_command('clear_diagram_cache', stdout=out)
# Check output
self.assertIn('Clearing all diagram caches', out.getvalue())
self.assertIn('Cache cleared successfully', out.getvalue())
def test_command_with_type(self):
"""Test running command with specific diagram type"""
# Create test cache
cache_dir = os.path.join(self.test_media_root, 'diagram_cache', 'mermaid')
os.makedirs(cache_dir, exist_ok=True)
test_file = os.path.join(cache_dir, 'test.svg')
open(test_file, 'w').close()
# Run command
out = StringIO()
call_command('clear_diagram_cache', type='mermaid', stdout=out)
# Check output
self.assertIn('Clearing cache for mermaid', out.getvalue())
self.assertIn('Cache cleared successfully', out.getvalue())
class IntegrationTest(TestCase):
"""Integration tests with actual dokumente models"""
def setUp(self):
"""Set up test data using dokumente models"""
from dokumente.models import (
Dokumententyp, Dokument, Vorgabe, VorgabeLangtext, Thema
)
from datetime import date
# Create required objects
self.dokumententyp = Dokumententyp.objects.create(
name="Test Policy",
verantwortliche_ve="TEST"
)
self.dokument = Dokument.objects.create(
nummer="TEST-001",
name="Test Document",
dokumententyp=self.dokumententyp,
aktiv=True
)
self.thema = Thema.objects.create(name="Test Thema")
self.vorgabe = Vorgabe.objects.create(
dokument=self.dokument,
nummer=1,
order=1,
thema=self.thema,
titel="Test Vorgabe",
gueltigkeit_von=date.today()
)
# Create AbschnittTypen
self.typ_text = AbschnittTyp.objects.create(abschnitttyp="text")
# Create VorgabeLangtext (which inherits from Textabschnitt)
self.langtext = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.typ_text,
inhalt="# Test\n\nThis is a **test** vorgabe.",
order=1
)
def test_render_vorgabe_langtext(self):
"""Test rendering VorgabeLangtext through render_textabschnitte"""
from dokumente.models import VorgabeLangtext
result = render_textabschnitte(
VorgabeLangtext.objects.filter(abschnitt=self.vorgabe).order_by('order')
)
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "text")
self.assertIn("<h1>Test</h1>", html)
self.assertIn("<strong>test</strong>", html)
self.assertIn("vorgabe", html)
def test_textabschnitt_inheritance(self):
"""Test that VorgabeLangtext properly inherits Textabschnitt fields"""
self.assertEqual(self.langtext.abschnitttyp, self.typ_text)
self.assertIn("test", self.langtext.inhalt)
self.assertEqual(self.langtext.order, 1)

View File

@@ -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;
}

View File

@@ -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(' <span class="toggle-hint">(klicken zum umschalten)</span>');
$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);

View File

@@ -18,14 +18,14 @@ spec:
fsGroupChangePolicy: "OnRootMismatch"
initContainers:
- name: loader
image: git.baumann.gr/adebaumann/vui-data-loader:0.8
image: git.baumann.gr/adebaumann/vui-data-loader:0.9
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ]
volumeMounts:
- name: data
mountPath: /data
containers:
- name: web
image: git.baumann.gr/adebaumann/vui:0.933
image: git.baumann.gr/adebaumann/vui:0.941
imagePullPolicy: Always
ports:
- containerPort: 8000

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,7 @@ from nested_admin import NestedStackedInline, NestedModelAdmin, NestedTabularInl
from django import forms
from mptt.forms import TreeNodeMultipleChoiceField
from mptt.admin import DraggableMPTTAdmin
from adminsortable2.admin import SortableInlineAdminMixin, SortableAdminBase
# Register your models here.
from .models import *
@@ -20,21 +21,33 @@ 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(NestedStackedInline):
model=VorgabeLangtext
@@ -42,42 +55,75 @@ class VorgabeLangtextInline(NestedStackedInline):
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):
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)
referenzen = TreeNodeMultipleChoiceField(queryset=Referenz.objects.all(), required=False)
class Meta:
model = Vorgabe
fields = '__all__'
class VorgabeInline(NestedTabularInline): # or StackedInline for more vertical layout
class VorgabeInline(SortableInlineAdminMixin, NestedStackedInline):
model = Vorgabe
form = VorgabeForm
extra = 0
#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
class StichworterklaerungInline(NestedStackedInline):
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
extra=0
sortable_field_name = "order"
@@ -100,27 +146,70 @@ class PersonAdmin(admin.ModelAdmin):
@admin.register(Dokument)
class DokumentAdmin(NestedModelAdmin):
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']
class Media:
# js = ('admin/js/vorgabe_collapse.js',)
css = {
'all': ('admin/css/vorgabe_border.css',
# 'admin/css/vorgabe_collapse.css',
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',)
css = {
'all': ('admin/css/vorgabe_border.css',)
}
#admin.site.register(Stichwort)
@admin.register(VorgabenTable)
class VorgabenTableAdmin(admin.ModelAdmin):
list_display = ['order', 'nummer', 'dokument', 'thema', 'titel', 'gueltigkeit_von', 'gueltigkeit_bis']
list_display_links = ['dokument']
list_editable = ['order', 'nummer', 'thema', 'titel', 'gueltigkeit_von', 'gueltigkeit_bis']
list_filter = ['dokument', 'thema', 'gueltigkeit_von', 'gueltigkeit_bis']
search_fields = ['nummer', 'titel', 'dokument__nummer', 'dokument__name']
autocomplete_fields = ['dokument', 'thema', 'stichworte', 'referenzen', 'relevanz']
ordering = ['order']
list_per_page = 100
fieldsets = (
('Grunddaten', {
'fields': ('order', 'nummer', 'dokument', 'thema', 'titel')
}),
('Gültigkeit', {
'fields': ('gueltigkeit_von', 'gueltigkeit_bis')
}),
('Verknüpfungen', {
'fields': ('referenzen', 'stichworte', 'relevanz'),
'classes': ('collapse',)
}),
)
@admin.register(Thema)
class ThemaAdmin(admin.ModelAdmin):
search_fields = ['name']
ordering = ['name']
admin.site.register(Checklistenfrage)
admin.site.register(Dokumententyp)
#admin.site.register(Person)
admin.site.register(Thema)
#admin.site.register(Referenz, DraggableM§PTTAdmin)
admin.site.register(Vorgabe)

View File

@@ -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)")

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.5 on 2025-10-27 19:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dokumente', '0007_alter_changelog_options_and_more'),
]
operations = [
migrations.AddField(
model_name='dokument',
name='aktiv',
field=models.BooleanField(blank=True, default=False),
preserve_default=False,
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.5 on 2025-10-28 14:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dokumente', '0008_dokument_aktiv'),
]
operations = [
migrations.AlterModelOptions(
name='vorgabe',
options={'ordering': ['order'], 'verbose_name_plural': 'Vorgaben'},
),
migrations.AddField(
model_name='vorgabe',
name='order',
field=models.IntegerField(default=0),
preserve_default=False,
),
]

View File

@@ -47,6 +47,7 @@ class Dokument(models.Model):
gueltigkeit_bis = models.DateField(null=True, blank=True)
signatur_cso = models.CharField(max_length=255, blank=True)
anhaenge = models.TextField(blank=True)
aktiv = models.BooleanField(blank=True)
def __str__(self):
return f"{self.nummer} {self.name}"
@@ -56,6 +57,7 @@ class Dokument(models.Model):
verbose_name="Dokument"
class Vorgabe(models.Model):
order = models.IntegerField()
nummer = models.IntegerField()
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='vorgaben')
thema = models.ForeignKey(Thema, on_delete=models.PROTECT)
@@ -76,7 +78,7 @@ class Vorgabe(models.Model):
if not self.gueltigkeit_bis:
return "active"
if self.gueltigkeit_bis > check_date:
if self.gueltigkeit_bis >= check_date:
return "active"
return "expired" if not verbose else "Ist seit dem "+self.gueltigkeit_bis.strftime('%d.%m.%Y')+" nicht mehr in Kraft."
@@ -84,9 +86,126 @@ 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"überschneiden sich in der Geltungsdauer"
})
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']
class VorgabeLangtext(Textabschnitt):
abschnitt=models.ForeignKey(Vorgabe,on_delete=models.CASCADE)
@@ -123,6 +242,12 @@ class Checklistenfrage(models.Model):
verbose_name_plural="Fragen für Checkliste"
verbose_name="Frage für Checkliste"
class VorgabenTable(Vorgabe):
class Meta:
proxy = True
verbose_name = "Vorgabe (Tabellenansicht)"
verbose_name_plural = "Vorgaben (Tabellenansicht)"
class Changelog(models.Model):
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='changelog')
autoren = models.ManyToManyField(Person)

View File

@@ -8,7 +8,7 @@
<!-- Autoren, Prüfende etc. -->
<p><strong>Autoren:</strong> {{ standard.autoren.all|join:", " }}</p>
<p><strong>Prüfende:</strong> {{ standard.pruefende.all|join:", " }}</p>
<p><strong>Gültigkeit:</strong> {{ standard.gueltigkeit_von }} bis {{ standard.gueltigkeit_bis }}</p>
<p><strong>Gültigkeit:</strong> {{ standard.gueltigkeit_von }} bis {{ standard.gueltigkeit_bis|default_if_none:"auf weiteres" }}</p>
<!-- Start Einleitung -->
{% if standard.einleitung_html %}

View File

@@ -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
@@ -114,7 +117,8 @@ class DokumentModelTest(TestCase):
name="Security Policy",
gueltigkeit_von=date.today(),
signatur_cso="CSO-123",
anhaenge="Appendix A, B"
anhaenge="Appendix A, B",
aktiv=True
)
self.dokument.autoren.add(self.autor)
self.dokument.pruefende.add(self.pruefer)
@@ -124,6 +128,7 @@ class DokumentModelTest(TestCase):
self.assertEqual(self.dokument.nummer, "DOC-001")
self.assertEqual(self.dokument.name, "Security Policy")
self.assertEqual(self.dokument.dokumententyp, self.dokumententyp)
self.assertEqual(self.dokument.aktiv, True)
def test_dokument_str(self):
"""Test string representation of Dokument"""
@@ -139,7 +144,8 @@ class DokumentModelTest(TestCase):
dokument = Dokument.objects.create(
nummer="DOC-002",
dokumententyp=self.dokumententyp,
name="Test Document"
name="Test Document",
aktiv=True
)
self.assertIsNone(dokument.gueltigkeit_von)
self.assertIsNone(dokument.gueltigkeit_bis)
@@ -158,10 +164,12 @@ class VorgabeModelTest(TestCase):
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
name="IT Standard"
name="IT Standard",
aktiv=True
)
self.thema = Thema.objects.create(name="Security")
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
@@ -171,6 +179,7 @@ class VorgabeModelTest(TestCase):
def test_vorgabe_creation(self):
"""Test that Vorgabe is created correctly"""
self.assertEqual(self.vorgabe.order, 1)
self.assertEqual(self.vorgabe.nummer, 1)
self.assertEqual(self.vorgabe.dokument, self.dokument)
self.assertEqual(self.vorgabe.thema, self.thema)
@@ -193,6 +202,7 @@ class VorgabeModelTest(TestCase):
def test_get_status_future(self):
"""Test get_status returns 'future' for future vorgabe"""
future_vorgabe = Vorgabe.objects.create(
order=2,
nummer=2,
dokument=self.dokument,
thema=self.thema,
@@ -205,6 +215,7 @@ class VorgabeModelTest(TestCase):
def test_get_status_expired(self):
"""Test get_status returns 'expired' for expired vorgabe"""
expired_vorgabe = Vorgabe.objects.create(
order=3,
nummer=3,
dokument=self.dokument,
thema=self.thema,
@@ -218,6 +229,7 @@ class VorgabeModelTest(TestCase):
def test_get_status_verbose(self):
"""Test get_status with verbose=True"""
future_vorgabe = Vorgabe.objects.create(
order=4,
nummer=4,
dokument=self.dokument,
thema=self.thema,
@@ -231,6 +243,7 @@ class VorgabeModelTest(TestCase):
def test_get_status_with_custom_check_date(self):
"""Test get_status with custom check_date"""
vorgabe = Vorgabe.objects.create(
order=5,
nummer=5,
dokument=self.dokument,
thema=self.thema,
@@ -254,10 +267,12 @@ class VorgabeTextAbschnitteTest(TestCase):
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
name="Test Standard"
name="Test Standard",
aktiv=True
)
self.thema = Thema.objects.create(name="Testing")
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
@@ -302,7 +317,8 @@ class DokumentTextAbschnitteTest(TestCase):
self.dokument = Dokument.objects.create(
nummer="POL-001",
dokumententyp=self.dokumententyp,
name="Test Policy"
name="Test Policy",
aktiv=True
)
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="Paragraph"
@@ -342,10 +358,12 @@ class ChecklistenfrageModelTest(TestCase):
self.dokument = Dokument.objects.create(
nummer="QA-001",
dokumententyp=self.dokumententyp,
name="QA Standard"
name="QA Standard",
aktiv=True
)
self.thema = Thema.objects.create(name="Quality")
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
@@ -382,7 +400,8 @@ class ChangelogModelTest(TestCase):
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
name="IT Standard"
name="IT Standard",
aktiv=True
)
self.autor = Person.objects.create(
name="John Doe",
@@ -419,10 +438,12 @@ class ViewsTestCase(TestCase):
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
name="Test Standard"
name="Test Standard",
aktiv=True
)
self.thema = Thema.objects.create(name="Testing")
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
@@ -495,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)

123
dokumente/utils.py Normal file
View 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)

View File

@@ -28,6 +28,6 @@
<div class="flex-fill">{% block content %}Main Content{% endblock %}</div>
<div class="col-md-2">{% block sidebar_right %}{% endblock %}</div>
</div>
<div>VorgabenUI v0.931</div>
<div>VorgabenUI v0.941</div>
</body>
</html>

View File

@@ -6,7 +6,7 @@ import datetime
import pprint
def startseite(request):
standards=list(Dokument.objects.all())
standards=list(Dokument.objects.filter(aktiv=True))
return render(request, 'startseite.html', {"dokumente":standards,})
def search(request):

View File

@@ -13,4 +13,4 @@ class ReferenzerklaerungInline(NestedStackedInline):
class ReferenzAdmin(NestedModelAdmin):
inlines=[ReferenzerklaerungInline]
list_display =['Path']
search_fields=("referenz",)
search_fields=("referenz","path")

View File

@@ -6,6 +6,7 @@ charset-normalizer==3.4.3
curtsies==0.4.3
cwcwidth==0.1.10
Django==5.2.5
django-admin-sortable2==2.2.8
django-js-asset==3.1.2
django-mptt==0.17.0
django-mptt-admin==2.8.0

View File

@@ -1,14 +1,39 @@
/* Style each Vorgabe inline block */
.djn-dynamic-form-Standards-vorgabe {
border: 2px solid #ccc;
.djn-dynamic-form-Standards-vorgabe,
.djn-dynamic-form-dokumente-vorgabe {
border: 3px solid #2c5aa0;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
background-color: #f9f9f9;
margin-bottom: 50px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
/* Make Vorgabe title prominent */
.djn-dynamic-form-Standards-vorgabe > h3,
.djn-dynamic-form-dokumente-vorgabe > h3 {
font-size: 18px;
font-weight: 700;
color: #2c5aa0;
margin: -15px -15px 15px -15px;
padding: 12px 15px;
background: linear-gradient(to bottom, #e8f0f8, #d4e4f3);
border-bottom: 2px solid #2c5aa0;
border-radius: 5px 5px 0 0;
}
/* Make Vorgabe identifier in tabular view prominent */
tbody.djn-dynamic-form-Standards-vorgabe td.original,
tbody.djn-dynamic-form-dokumente-vorgabe td.original,
tbody.djn-dynamic-form-Standards-vorgabe td.original p,
tbody.djn-dynamic-form-dokumente-vorgabe td.original p {
font-size: 16px !important;
font-weight: 700 !important;
color: #2c5aa0 !important;
}
/* Optional: Slight padding for inner fieldsets (e.g., Langtext/Kurztext inlines) */
.djn-dynamic-form-Standards-vorgabe .inline-related {
.djn-dynamic-form-Standards-vorgabe .inline-related,
.djn-dynamic-form-dokumente-vorgabe .inline-related {
margin-top: 10px;
padding-left: 10px;
border-left: 2px dashed #ccc;

View File

@@ -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';
// Find the existing title/header within the vorgabe block
const existingHeader = block.querySelector('h3, .inline-label, .module h2, .djn-inline-header');
block.parentNode.insertBefore(header, block);
if (existingHeader) {
// Make the existing header clickable for collapse/expand
existingHeader.style.cursor = 'pointer';
existingHeader.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
header.addEventListener('click', () => {
const isHidden = block.style.display === 'none';
block.style.display = isHidden ? '' : 'none';
header.innerHTML = `${isHidden ? '▼' : '▶'} Vorgabe ${index + 1}`;
// 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}`;
});
}, 500); // wait 500ms to allow nested inlines to render
// Add initial collapse indicator
const originalText = existingHeader.textContent;
existingHeader.innerHTML = `${originalText}`;
}
});
}, 1000); // wait longer to allow nested inlines to render
});

38
test_sanity_check.py Normal file
View File

@@ -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()