Compare commits

...

58 Commits

Author SHA1 Message Date
718160c8b7 First version of helm-charts 2025-11-01 01:24:09 +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
957a1b9255 Changed tests to be more in line with our terms 2025-10-27 10:03:58 +01:00
afc07d4561 Fix tests: Update field names to match actual model structure (abschnitttyp field, inhalt field) 2025-10-24 17:58:54 +00:00
af06598172 Fix tests: Update Abschnitttyp to AbschnittTyp 2025-10-24 17:54:59 +00:00
4213ca60ac Add comprehensive unit tests for dokumente app 2025-10-24 17:48:08 +00:00
bf2f15fa5c removed initial test lines 2025-10-24 19:42:07 +02:00
c1eb2d7871 Deploy 0.933 2025-10-24 01:07:37 +02:00
b29e894b22 Merge branch 'feature/diagram-post-caching' into testing 2025-10-24 01:04:54 +02:00
0f096d18aa Add MEDIA_ROOT, MEDIA_URL and DIAGRAM_CACHE_DIR to docker settings 2025-10-23 23:00:14 +00:00
9b484787a4 Add media file serving for cached diagrams
- Configure MEDIA_URL/MEDIA_ROOT serving in DEBUG mode
- Separate static and media file configurations for clarity
2025-10-23 22:59:54 +00:00
8dd3b4e9af Add MEDIA_ROOT, MEDIA_URL and DIAGRAM_CACHE_DIR settings 2025-10-23 22:59:40 +00:00
0d0199ca62 Wrong branching point - corrected 2025-10-24 00:54:10 +02:00
5f58d660c0 Merge branch 'feature/diagram-post-caching' into testing 2025-10-24 00:47:18 +02:00
e84f25ca1d New DB 2025-10-24 00:37:40 +02:00
dfb8eeef97 Deploy 931 (with diagram branch in container) - readiness probes off temporarily 2025-10-24 00:30:22 +02:00
0225fb3396 Deploy 931 (with diagram branch in container) - 2nd attempt 2025-10-24 00:27:07 +02:00
7377ddaea3 Deploy 931 (with diagram branch in container) 2025-10-24 00:20:34 +02:00
67c393ecf1 Add documentation for diagram POST caching feature 2025-10-23 22:06:30 +00:00
dbb3ecd5bf Add diagram cache directory to gitignore 2025-10-23 22:06:07 +00:00
966cd46228 Add management command to clear diagram cache
Usage:
  python manage.py clear_diagram_cache
  python manage.py clear_diagram_cache --type plantuml
2025-10-23 22:05:36 +00:00
1ee9b3c46f Add commands package 2025-10-23 22:05:26 +00:00
8f57f5fc5b Add management module 2025-10-23 22:05:21 +00:00
cd7195b3aa Update diagram rendering to use POST with caching
- Replace URL-encoded GET approach with POST requests
- Use local filesystem cache for generated diagrams
- Add error handling with fallback message
- Serve diagrams from MEDIA_URL instead of proxy
2025-10-23 22:05:13 +00:00
020dff0871 Add diagram caching module with POST support
- Implement content-based hashing for cache keys
- POST diagram content to Kroki server instead of URL encoding
- Store generated SVGs in local filesystem cache
- Add cache clearing functionality
2025-10-23 22:04:19 +00:00
1dbdbc7f3c Initialize diagramm_proxy module 2025-10-23 22:03:39 +00:00
4d1232b764 Tests - not runnable yet. 2025-10-23 16:35:31 +02:00
fe2e02934a README added, first try at signing commits. 2025-10-23 09:27:25 +02:00
add1a88ce4 README added, first try at signing commits. 2025-10-23 09:26:44 +02:00
3c23918e1f No-clobber back on for database in ArgoCD 2025-10-23 09:01:45 +02:00
fa0a2a9df9 new data structure due to renaming - clobbering database temporarily in init-container 2025-10-23 08:25:12 +00:00
Adrian A. Baumann
9feaf6686f Deploy 930 2025-10-23 09:42:41 +02:00
7087be672a Added "Geltungsbereich" back into search function and corrected it; Changed "standards" page to "dokumente" internally 2025-10-23 09:35:23 +02:00
Adrian A. Baumann
969141601d Merge branch 'rename_standards' into development 2025-10-22 15:14:48 +02:00
Adrian A. Baumann
b391ab0ef6 >Renamed app "standards" to "dokumente" - finally working as expected. 2025-10-22 15:08:42 +02:00
4de2ad38c5 Readme added 2025-10-21 23:46:17 +02:00
d46d937e93 Geltungsbereich removed from search scope for now. Maybe check Haystack or other dedicated search engines 2025-10-21 16:16:42 +02:00
54 changed files with 2109 additions and 75 deletions

5
.gitignore vendored
View File

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

View File

@@ -0,0 +1,105 @@
# Diagram POST Caching Implementation
This feature replaces the URL-encoded GET approach for diagram generation with POST requests and local filesystem caching.
## Changes Overview
### New Files
- `diagramm_proxy/__init__.py` - Module initialization
- `diagramm_proxy/diagram_cache.py` - Caching logic and POST request handling
- `abschnitte/management/commands/clear_diagram_cache.py` - Management command for cache clearing
### Modified Files
- `abschnitte/utils.py` - Updated `render_textabschnitte()` to use caching
- `.gitignore` - Added cache directory exclusion
## Configuration Required
Add to your Django settings file (e.g., `VorgabenUI/settings.py`):
```python
# Diagram cache settings
DIAGRAM_CACHE_DIR = 'diagram_cache' # relative to MEDIA_ROOT
# Ensure MEDIA_ROOT and MEDIA_URL are configured
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
```
### URL Configuration
Ensure media files are served in development. In your main `urls.py`:
```python
from django.conf import settings
from django.conf.urls.static import static
# ... existing urlpatterns ...
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
```
## How It Works
1. When a diagram is rendered, the system computes a SHA256 hash of the diagram content
2. It checks if a cached SVG exists for that hash
3. If cached: serves the existing file
4. If not cached: POSTs content to Kroki server, saves the response, and serves it
5. Diagrams are served from `MEDIA_URL/diagram_cache/{type}/{hash}.svg`
## Benefits
- **No URL length limitations** - Content is POSTed instead of URL-encoded
- **Improved performance** - Cached diagrams are served directly from filesystem
- **Reduced server load** - Kroki server is only called once per unique diagram
- **Persistent cache** - Survives application restarts
- **Better error handling** - Graceful fallback on generation failures
## Usage
### Viewing Diagrams
No changes required - diagrams will be automatically cached on first render.
### Clearing Cache
Clear all cached diagrams:
```bash
python manage.py clear_diagram_cache
```
Clear diagrams of a specific type:
```bash
python manage.py clear_diagram_cache --type plantuml
python manage.py clear_diagram_cache --type mermaid
```
## Testing
1. Create or view a page with diagrams
2. Verify diagrams render correctly
3. Check that `media/diagram_cache/` directory is created with cached SVGs
4. Refresh the page - second load should be faster (cache hit)
5. Check logs for cache hit/miss messages
6. Test cache clearing command
## Migration Notes
- Existing diagrams will be regenerated on first view after deployment
- The old URL-based approach is completely replaced
- No database migrations needed
- Ensure `requests` library is installed (already in requirements.txt)
## Troubleshooting
### Diagrams not rendering
- Check that MEDIA_ROOT and MEDIA_URL are configured correctly
- Verify Kroki server is accessible at `http://svckroki:8000`
- Check application logs for error messages
- Ensure media directory is writable
### Cache not working
- Verify Django storage configuration
- Check file permissions on media/diagram_cache directory
- Review logs for cache-related errors

View File

@@ -1 +1,6 @@
# VDeployment2
# vgui-cicd
There are examples for importing text in the "Documentation"-directory. Actual documentation follows.
Documentation on Confluence so far.
This commit should be signed.

View File

@@ -38,7 +38,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'standards',
'dokumente',
'abschnitte',
'stichworte',
'mptt',
@@ -126,6 +126,13 @@ STATICFILES_DIRS= (
os.path.join(BASE_DIR,"static"),
)
# Media files (User-uploaded content)
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Diagram cache settings
DIAGRAM_CACHE_DIR = 'diagram_cache' # relative to MEDIA_ROOT
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@@ -43,7 +43,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'standards',
'dokumente',
'abschnitte',
'stichworte',
'referenzen',
@@ -139,6 +139,13 @@ STATICFILES_DIRS= (
os.path.join(BASE_DIR,"static"),
)
# Media files (User-uploaded content)
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Diagram cache settings
DIAGRAM_CACHE_DIR = 'diagram_cache' # relative to MEDIA_ROOT
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@@ -19,7 +19,7 @@ from django.urls import include, path, re_path
from django.conf import settings
from django.conf.urls.static import static
from diagramm_proxy.views import DiagrammProxyView
import standards.views
import dokumente.views
import pages.views
import referenzen.views
@@ -28,11 +28,17 @@ admin.site.site_header="Autorenumgebung"
urlpatterns = [
path('',pages.views.startseite),
path('search/',pages.views.search),
path('standards/', include("standards.urls")),
path('dokumente/', include("dokumente.urls")),
path('autorenumgebung/', admin.site.urls),
path('stichworte/', include("stichworte.urls")),
path('referenzen/', referenzen.views.tree, name="referenz_tree"),
path('referenzen/<str:refid>/', referenzen.views.detail, name="referenz_detail"),
re_path(r'^diagramm/(?P<path>.*)$', DiagrammProxyView.as_view()),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
]
# Serve static files
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Serve media files (including cached diagrams)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -0,0 +1 @@
# Management commands

View File

@@ -0,0 +1 @@
# Commands package

View File

@@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
from diagramm_proxy.diagram_cache import clear_cache
class Command(BaseCommand):
help = 'Clear cached diagrams'
def add_arguments(self, parser):
parser.add_argument(
'--type',
type=str,
help='Diagram type to clear (e.g., plantuml, mermaid)',
)
def handle(self, *args, **options):
diagram_type = options.get('type')
if diagram_type:
self.stdout.write(f'Clearing cache for {diagram_type}...')
clear_cache(diagram_type)
else:
self.stdout.write('Clearing all diagram caches...')
clear_cache()
self.stdout.write(self.style.SUCCESS('Cache cleared successfully'))

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

@@ -3,6 +3,10 @@ import base64
import zlib
import re
from textwrap import dedent
from django.conf import settings
# Import the caching function
from diagramm_proxy.diagram_cache import get_cached_diagram
DIAGRAMMSERVER="/diagramm"
@@ -25,15 +29,23 @@ def render_textabschnitte(queryset):
elif typ == "tabelle":
html = md_table_to_html(inhalt)
elif typ == "diagramm":
temp=inhalt.splitlines()
diagramtype=temp.pop(0)
diagramoptions='width="100%"'
if temp[0][0:6].lower() == "option":
diagramoptions=temp.pop(0).split(":",1)[1]
rest="\n".join(temp)
html = '<p><img '+diagramoptions+' src="'+DIAGRAMMSERVER+"/"+diagramtype+"/svg/"
html += base64.urlsafe_b64encode(zlib.compress(rest.encode("utf-8"),9)).decode()
html += '"></p>'
temp = inhalt.splitlines()
diagramtype = temp.pop(0)
diagramoptions = 'width="100%"'
if temp and temp[0][0:6].lower() == "option":
diagramoptions = temp.pop(0).split(":", 1)[1]
rest = "\n".join(temp)
# Use caching instead of URL encoding
try:
cache_path = get_cached_diagram(diagramtype, rest)
# Generate URL to serve from media/static
diagram_url = settings.MEDIA_URL + cache_path
html = f'<p><img {diagramoptions} src="{diagram_url}"></p>'
except Exception as e:
# Fallback to error message
html = f'<p class="text-danger">Error generating diagram: {str(e)}</p>'
elif typ == "code":
html = "<pre><code>"
html += markdown(inhalt, extensions=['tables', 'attr_list'])

View File

@@ -18,14 +18,14 @@ spec:
fsGroupChangePolicy: "OnRootMismatch"
initContainers:
- name: loader
image: git.baumann.gr/adebaumann/vgui-data-loader:0.5
image: git.baumann.gr/adebaumann/vui-data-loader:0.8
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.929
image: git.baumann.gr/adebaumann/vui:0.939
imagePullPolicy: Always
ports:
- containerPort: 8000

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
# Diagram proxy module

View File

@@ -0,0 +1,91 @@
import hashlib
import os
import requests
from pathlib import Path
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
import logging
logger = logging.getLogger(__name__)
# Configure cache directory
CACHE_DIR = getattr(settings, 'DIAGRAM_CACHE_DIR', 'diagram_cache')
KROKI_UPSTREAM = "http://svckroki:8000"
def get_cache_path(diagram_type, content_hash):
"""Generate cache file path for a diagram."""
return os.path.join(CACHE_DIR, diagram_type, f"{content_hash}.svg")
def compute_hash(content):
"""Compute SHA256 hash of diagram content."""
return hashlib.sha256(content.encode('utf-8')).hexdigest()
def get_cached_diagram(diagram_type, diagram_content):
"""
Retrieve diagram from cache or generate it via POST.
Args:
diagram_type: Type of diagram (e.g., 'plantuml', 'mermaid')
diagram_content: Raw diagram content
Returns:
Path to cached diagram file (relative to MEDIA_ROOT)
"""
content_hash = compute_hash(diagram_content)
cache_path = get_cache_path(diagram_type, content_hash)
# Check if diagram exists in cache
if default_storage.exists(cache_path):
logger.debug(f"Cache hit for {diagram_type} diagram: {content_hash[:8]}")
return cache_path
# Generate diagram via POST request
logger.info(f"Cache miss for {diagram_type} diagram: {content_hash[:8]}, generating...")
try:
url = f"{KROKI_UPSTREAM}/{diagram_type}/svg"
response = requests.post(
url,
data=diagram_content.encode('utf-8'),
headers={'Content-Type': 'text/plain'},
timeout=30
)
response.raise_for_status()
# Ensure cache directory exists
cache_dir = os.path.dirname(default_storage.path(cache_path))
os.makedirs(cache_dir, exist_ok=True)
# Save to cache
default_storage.save(cache_path, ContentFile(response.content))
logger.info(f"Diagram cached successfully: {cache_path}")
return cache_path
except requests.RequestException as e:
logger.error(f"Error generating diagram: {e}")
raise
def clear_cache(diagram_type=None):
"""
Clear cached diagrams.
Args:
diagram_type: If specified, only clear diagrams of this type
"""
if diagram_type:
cache_path = os.path.join(CACHE_DIR, diagram_type)
else:
cache_path = CACHE_DIR
if default_storage.exists(cache_path):
full_path = default_storage.path(cache_path)
# Walk through and delete files
for root, dirs, files in os.walk(full_path):
for file in files:
file_path = os.path.join(root, file)
try:
os.remove(file_path)
logger.info(f"Deleted cached diagram: {file_path}")
except OSError as e:
logger.error(f"Error deleting {file_path}: {e}")

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 *
@@ -36,7 +37,7 @@ class VorgabeKurztextInline(NestedTabularInline):
classes = ['collapse']
#inline=inhalt
class VorgabeLangtextInline(NestedStackedInline):
class VorgabeLangtextInline(NestedTabularInline):
model=VorgabeLangtext
extra=0
sortable_field_name = "order"
@@ -61,15 +62,16 @@ class EinleitungInline(NestedTabularInline):
classes = ['collapse']
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, NestedTabularInline): # or StackedInline for more vertical layout
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]
autocomplete_fields = ['stichworte','referenzen','relevanz']
@@ -77,7 +79,7 @@ class VorgabeInline(NestedTabularInline): # or StackedInline for more vertical
list_filter=['stichworte']
#classes=["collapse"]
class StichworterklaerungInline(NestedStackedInline):
class StichworterklaerungInline(NestedTabularInline):
model=Stichworterklaerung
extra=0
sortable_field_name = "order"
@@ -100,7 +102,7 @@ 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']
@@ -117,11 +119,39 @@ class DokumentAdmin(NestedModelAdmin):
#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)
#admin.site.register(Changelog)

View File

@@ -3,4 +3,4 @@ from django.apps import AppConfig
class standardsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'standards'
name = 'dokumente'

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from standards.models import (
from dokumente.models import (
Dokument,
Dokumententyp,
Thema,

View File

@@ -53,7 +53,7 @@ class Migration(migrations.Migration):
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
('oberreferenz', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='unterreferenzen', to='standards.referenz')),
('oberreferenz', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='unterreferenzen', to='dokumente.referenz')),
],
options={
'verbose_name_plural': 'Referenzen',
@@ -65,7 +65,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('inhalt', models.TextField(blank=True, null=True)),
('abschnitttyp', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='abschnitte.abschnitttyp')),
('erklaerung', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='standards.referenz')),
('erklaerung', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dokumente.referenz')),
],
options={
'verbose_name': 'Erklärung',
@@ -80,9 +80,9 @@ class Migration(migrations.Migration):
('gueltigkeit_bis', models.DateField(blank=True, null=True)),
('signatur_cso', models.CharField(blank=True, max_length=255)),
('anhaenge', models.TextField(blank=True)),
('autoren', models.ManyToManyField(related_name='verfasste_dokumente', to='standards.person')),
('dokumententyp', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='standards.dokumententyp')),
('pruefende', models.ManyToManyField(related_name='gepruefte_dokumente', to='standards.person')),
('autoren', models.ManyToManyField(related_name='verfasste_dokumente', to='dokumente.person')),
('dokumententyp', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dokumente.dokumententyp')),
('pruefende', models.ManyToManyField(related_name='gepruefte_dokumente', to='dokumente.person')),
],
options={
'verbose_name': 'Standard',
@@ -95,7 +95,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('inhalt', models.TextField(blank=True, null=True)),
('abschnitttyp', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='abschnitte.abschnitttyp')),
('geltungsbereich', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='standards.standard')),
('geltungsbereich', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dokumente.standard')),
],
options={
'verbose_name': 'Geltungsbereichs-Abschnitt',
@@ -108,8 +108,8 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datum', models.DateField()),
('aenderung', models.TextField()),
('autoren', models.ManyToManyField(to='standards.person')),
('dokument', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changelog', to='standards.standard')),
('autoren', models.ManyToManyField(to='dokumente.person')),
('dokument', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changelog', to='dokumente.standard')),
],
),
migrations.CreateModel(
@@ -120,10 +120,10 @@ class Migration(migrations.Migration):
('titel', models.CharField(max_length=255)),
('gueltigkeit_von', models.DateField()),
('gueltigkeit_bis', models.DateField(blank=True, null=True)),
('dokument', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vorgaben', to='standards.standard')),
('referenzen', models.ManyToManyField(blank=True, to='standards.referenz')),
('dokument', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vorgaben', to='dokumente.standard')),
('referenzen', models.ManyToManyField(blank=True, to='dokumente.referenz')),
('stichworte', models.ManyToManyField(blank=True, to='stichworte.stichwort')),
('thema', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='standards.thema')),
('thema', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dokumente.thema')),
],
options={
'verbose_name_plural': 'Vorgaben',
@@ -134,7 +134,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('frage', models.CharField(max_length=255)),
('vorgabe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checklistenfragen', to='standards.vorgabe')),
('vorgabe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checklistenfragen', to='dokumente.vorgabe')),
],
options={
'verbose_name_plural': 'Fragen für Checkliste',
@@ -145,7 +145,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('inhalt', models.TextField(blank=True, null=True)),
('abschnitt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='standards.vorgabe')),
('abschnitt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dokumente.vorgabe')),
('abschnitttyp', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='abschnitte.abschnitttyp')),
],
options={
@@ -158,7 +158,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('inhalt', models.TextField(blank=True, null=True)),
('abschnitt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='standards.vorgabe')),
('abschnitt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dokumente.vorgabe')),
('abschnitttyp', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='abschnitte.abschnitttyp')),
],
options={

View File

@@ -8,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
('abschnitte', '0001_initial'),
('standards', '0001_initial'),
('dokumente', '0001_initial'),
]
operations = [
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('inhalt', models.TextField(blank=True, null=True)),
('abschnitttyp', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='abschnitte.abschnitttyp')),
('einleitung', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='standards.standard')),
('einleitung', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dokumente.standard')),
],
options={
'verbose_name': 'Einleitungs-Abschnitt',

View File

@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('standards', '0002_einleitung'),
('dokumente', '0002_einleitung'),
]
operations = [

View File

@@ -7,7 +7,7 @@ class Migration(migrations.Migration):
dependencies = [
('referenzen', '0001_initial'),
('standards', '0003_einleitung_order_geltungsbereich_order_and_more'),
('dokumente', '0003_einleitung_order_geltungsbereich_order_and_more'),
]
operations = [

View File

@@ -7,7 +7,7 @@ class Migration(migrations.Migration):
dependencies = [
('rollen', '0001_initial'),
('standards', '0004_remove_referenzerklaerung_erklaerung_and_more'),
('dokumente', '0004_remove_referenzerklaerung_erklaerung_and_more'),
]
operations = [

View File

@@ -6,7 +6,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('standards', '0005_vorgabe_relevanz'),
('dokumente', '0005_vorgabe_relevanz'),
]
operations = [

View File

@@ -6,7 +6,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('standards', '0006_rename_standard_dokument_alter_dokument_options'),
('dokumente', '0006_rename_standard_dokument_alter_dokument_options'),
]
operations = [

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)
@@ -86,7 +88,7 @@ class Vorgabe(models.Model):
class Meta:
verbose_name_plural="Vorgaben"
ordering = ['order']
class VorgabeLangtext(Textabschnitt):
abschnitt=models.ForeignKey(Vorgabe,on_delete=models.CASCADE)
@@ -123,6 +125,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

@@ -2,10 +2,10 @@
{% block content %}
<h1>Standards Informatiksicherheit</h1>
<ul>
{% for standard in standards %}
{% for dokument in dokumente %}
<li>
<a href="{% url 'standard_detail' nummer=standard.nummer %}">
{{ standard.nummer }} {{ standard.name }}
<a href="{% url 'standard_detail' nummer=dokument.nummer %}">
{{ dokument.nummer }} {{ dokument.name }}
</a>
</li>
{% endfor %}

515
dokumente/tests.py Normal file
View File

@@ -0,0 +1,515 @@
from django.test import TestCase, Client
from django.urls import reverse
from datetime import date, timedelta
from .models import (
Dokumententyp, Person, Thema, Dokument, Vorgabe,
VorgabeLangtext, VorgabeKurztext, Geltungsbereich,
Einleitung, Checklistenfrage, Changelog
)
from abschnitte.models import AbschnittTyp
from referenzen.models import Referenz
from stichworte.models import Stichwort
from rollen.models import Rolle
class DokumententypModelTest(TestCase):
"""Test cases for Dokumententyp model"""
def setUp(self):
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
def test_dokumententyp_creation(self):
"""Test that Dokumententyp is created correctly"""
self.assertEqual(self.dokumententyp.name, "Standard IT-Sicherheit")
self.assertEqual(self.dokumententyp.verantwortliche_ve, "SR-SUR-SEC")
def test_dokumententyp_str(self):
"""Test string representation of Dokumententyp"""
self.assertEqual(str(self.dokumententyp), "Standard IT-Sicherheit")
def test_dokumententyp_verbose_name(self):
"""Test verbose name"""
self.assertEqual(
Dokumententyp._meta.verbose_name,
"Dokumententyp"
)
self.assertEqual(
Dokumententyp._meta.verbose_name_plural,
"Dokumententypen"
)
class PersonModelTest(TestCase):
"""Test cases for Person model"""
def setUp(self):
self.person = Person.objects.create(
name="Max Mustermann",
funktion="Manager"
)
def test_person_creation(self):
"""Test that Person is created correctly"""
self.assertEqual(self.person.name, "Max Mustermann")
self.assertEqual(self.person.funktion, "Manager")
def test_person_str(self):
"""Test string representation of Person"""
self.assertEqual(str(self.person), "Max Mustermann")
def test_person_verbose_name_plural(self):
"""Test verbose name plural"""
self.assertEqual(
Person._meta.verbose_name_plural,
"Personen"
)
class ThemaModelTest(TestCase):
"""Test cases for Thema model"""
def setUp(self):
self.thema = Thema.objects.create(
name="Security",
erklaerung="Security related topics"
)
def test_thema_creation(self):
"""Test that Thema is created correctly"""
self.assertEqual(self.thema.name, "Security")
self.assertEqual(self.thema.erklaerung, "Security related topics")
def test_thema_str(self):
"""Test string representation of Thema"""
self.assertEqual(str(self.thema), "Security")
def test_thema_blank_erklaerung(self):
"""Test that erklaerung can be blank"""
thema = Thema.objects.create(name="Testing")
self.assertEqual(thema.erklaerung, "")
class DokumentModelTest(TestCase):
"""Test cases for Dokument model"""
def setUp(self):
self.dokumententyp = Dokumententyp.objects.create(
name="Policy",
verantwortliche_ve="Legal"
)
self.autor = Person.objects.create(
name="John Doe",
funktion="Author"
)
self.pruefer = Person.objects.create(
name="Jane Smith",
funktion="Reviewer"
)
self.dokument = Dokument.objects.create(
nummer="DOC-001",
dokumententyp=self.dokumententyp,
name="Security Policy",
gueltigkeit_von=date.today(),
signatur_cso="CSO-123",
anhaenge="Appendix A, B",
aktiv=True
)
self.dokument.autoren.add(self.autor)
self.dokument.pruefende.add(self.pruefer)
def test_dokument_creation(self):
"""Test that Dokument is created correctly"""
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"""
self.assertEqual(str(self.dokument), "DOC-001 Security Policy")
def test_dokument_many_to_many_relationships(self):
"""Test many-to-many relationships"""
self.assertIn(self.autor, self.dokument.autoren.all())
self.assertIn(self.pruefer, self.dokument.pruefende.all())
def test_dokument_optional_fields(self):
"""Test optional fields can be None or blank"""
dokument = Dokument.objects.create(
nummer="DOC-002",
dokumententyp=self.dokumententyp,
name="Test Document",
aktiv=True
)
self.assertIsNone(dokument.gueltigkeit_von)
self.assertIsNone(dokument.gueltigkeit_bis)
self.assertEqual(dokument.signatur_cso, "")
self.assertEqual(dokument.anhaenge, "")
class VorgabeModelTest(TestCase):
"""Test cases for Vorgabe model"""
def setUp(self):
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
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,
titel="Password Requirements",
gueltigkeit_von=date.today() - timedelta(days=30)
)
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)
def test_vorgabennummer(self):
"""Test Vorgabennummer generation"""
expected = "R01234.S.1"
self.assertEqual(self.vorgabe.Vorgabennummer(), expected)
def test_vorgabe_str(self):
"""Test string representation of Vorgabe"""
expected = "R01234.S.1: Password Requirements"
self.assertEqual(str(self.vorgabe), expected)
def test_get_status_active(self):
"""Test get_status returns 'active' for current vorgabe"""
status = self.vorgabe.get_status()
self.assertEqual(status, "active")
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,
titel="Future Requirement",
gueltigkeit_von=date.today() + timedelta(days=30)
)
status = future_vorgabe.get_status()
self.assertEqual(status, "future")
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,
titel="Old Requirement",
gueltigkeit_von=date.today() - timedelta(days=60),
gueltigkeit_bis=date.today() - timedelta(days=10)
)
status = expired_vorgabe.get_status()
self.assertEqual(status, "expired")
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,
titel="Future Test",
gueltigkeit_von=date.today() + timedelta(days=10)
)
status = future_vorgabe.get_status(verbose=True)
self.assertIn("Ist erst ab dem", status)
self.assertIn("in Kraft", status)
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,
titel="Test Requirement",
gueltigkeit_von=date.today() - timedelta(days=60),
gueltigkeit_bis=date.today() - timedelta(days=10)
)
check_date = date.today() - timedelta(days=30)
status = vorgabe.get_status(check_date=check_date)
self.assertEqual(status, "active")
class VorgabeTextAbschnitteTest(TestCase):
"""Test cases for VorgabeLangtext and VorgabeKurztext"""
def setUp(self):
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
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,
titel="Test Vorgabe",
gueltigkeit_von=date.today()
)
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="Paragraph"
)
def test_vorgabe_langtext_creation(self):
"""Test VorgabeLangtext creation"""
langtext = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp,
inhalt="This is a long text description",
order=1
)
self.assertEqual(langtext.abschnitt, self.vorgabe)
self.assertEqual(langtext.inhalt, "This is a long text description")
def test_vorgabe_kurztext_creation(self):
"""Test VorgabeKurztext creation"""
kurztext = VorgabeKurztext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp,
inhalt="Short summary",
order=1
)
self.assertEqual(kurztext.abschnitt, self.vorgabe)
self.assertEqual(kurztext.inhalt, "Short summary")
class DokumentTextAbschnitteTest(TestCase):
"""Test cases for Geltungsbereich and Einleitung"""
def setUp(self):
self.dokumententyp = Dokumententyp.objects.create(
name="Policy",
verantwortliche_ve="Legal"
)
self.dokument = Dokument.objects.create(
nummer="POL-001",
dokumententyp=self.dokumententyp,
name="Test Policy",
aktiv=True
)
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="Paragraph"
)
def test_geltungsbereich_creation(self):
"""Test Geltungsbereich creation"""
geltungsbereich = Geltungsbereich.objects.create(
geltungsbereich=self.dokument,
abschnitttyp=self.abschnitttyp,
inhalt="Applies to all employees",
order=1
)
self.assertEqual(geltungsbereich.geltungsbereich, self.dokument)
self.assertEqual(geltungsbereich.inhalt, "Applies to all employees")
def test_einleitung_creation(self):
"""Test Einleitung creation"""
einleitung = Einleitung.objects.create(
einleitung=self.dokument,
abschnitttyp=self.abschnitttyp,
inhalt="This document defines...",
order=1
)
self.assertEqual(einleitung.einleitung, self.dokument)
self.assertEqual(einleitung.inhalt, "This document defines...")
class ChecklistenfrageModelTest(TestCase):
"""Test cases for Checklistenfrage model"""
def setUp(self):
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="QA"
)
self.dokument = Dokument.objects.create(
nummer="QA-001",
dokumententyp=self.dokumententyp,
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,
titel="Quality Check",
gueltigkeit_von=date.today()
)
self.frage = Checklistenfrage.objects.create(
vorgabe=self.vorgabe,
frage="Have all tests passed?"
)
def test_checklistenfrage_creation(self):
"""Test Checklistenfrage creation"""
self.assertEqual(self.frage.vorgabe, self.vorgabe)
self.assertEqual(self.frage.frage, "Have all tests passed?")
def test_checklistenfrage_str(self):
"""Test string representation"""
self.assertEqual(str(self.frage), "Have all tests passed?")
def test_checklistenfrage_related_name(self):
"""Test related name works correctly"""
self.assertIn(self.frage, self.vorgabe.checklistenfragen.all())
class ChangelogModelTest(TestCase):
"""Test cases for Changelog model"""
def setUp(self):
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
name="IT Standard",
aktiv=True
)
self.autor = Person.objects.create(
name="John Doe",
funktion="Developer"
)
self.changelog = Changelog.objects.create(
dokument=self.dokument,
datum=date.today(),
aenderung="Initial version"
)
self.changelog.autoren.add(self.autor)
def test_changelog_creation(self):
"""Test Changelog creation"""
self.assertEqual(self.changelog.dokument, self.dokument)
self.assertEqual(self.changelog.aenderung, "Initial version")
self.assertIn(self.autor, self.changelog.autoren.all())
def test_changelog_str(self):
"""Test string representation"""
expected = f"{date.today()} R01234"
self.assertEqual(str(self.changelog), expected)
class ViewsTestCase(TestCase):
"""Test cases for views"""
def setUp(self):
self.client = Client()
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.dokument = Dokument.objects.create(
nummer="R01234",
dokumententyp=self.dokumententyp,
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,
titel="Test Requirement",
gueltigkeit_von=date.today()
)
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="Paragraph"
)
def test_standard_list_view(self):
"""Test standard_list view"""
response = self.client.get(reverse('standard_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "R01234")
self.assertIn('dokumente', response.context)
def test_standard_detail_view(self):
"""Test standard_detail view"""
response = self.client.get(
reverse('standard_detail', kwargs={'nummer': 'R01234'})
)
self.assertEqual(response.status_code, 200)
self.assertIn('standard', response.context)
self.assertIn('vorgaben', response.context)
self.assertEqual(response.context['standard'], self.dokument)
def test_standard_detail_view_404(self):
"""Test standard_detail view returns 404 for non-existent document"""
response = self.client.get(
reverse('standard_detail', kwargs={'nummer': 'NONEXISTENT'})
)
self.assertEqual(response.status_code, 404)
def test_standard_checkliste_view(self):
"""Test standard_checkliste view"""
response = self.client.get(
reverse('standard_checkliste', kwargs={'nummer': 'R01234'})
)
self.assertEqual(response.status_code, 200)
self.assertIn('standard', response.context)
self.assertIn('vorgaben', response.context)
def test_standard_history_view(self):
"""Test standard_detail with history (check_date)"""
url = reverse('standard_history', kwargs={'nummer': 'R01234'})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class URLPatternsTest(TestCase):
"""Test URL patterns"""
def test_standard_list_url_resolves(self):
"""Test that standard_list URL resolves correctly"""
url = reverse('standard_list')
self.assertEqual(url, '/dokumente/')
def test_standard_detail_url_resolves(self):
"""Test that standard_detail URL resolves correctly"""
url = reverse('standard_detail', kwargs={'nummer': 'TEST-001'})
self.assertEqual(url, '/dokumente/TEST-001/')
def test_standard_checkliste_url_resolves(self):
"""Test that standard_checkliste URL resolves correctly"""
url = reverse('standard_checkliste', kwargs={'nummer': 'TEST-001'})
self.assertEqual(url, '/dokumente/TEST-001/checkliste/')
def test_standard_history_url_resolves(self):
"""Test that standard_history URL resolves correctly"""
url = reverse('standard_history', kwargs={'nummer': 'TEST-001'})
self.assertEqual(url, '/dokumente/TEST-001/history/')

View File

@@ -9,9 +9,9 @@ calendar=parsedatetime.Calendar()
def standard_list(request):
standards = Dokument.objects.all()
dokumente = Dokument.objects.all()
return render(request, 'standards/standard_list.html',
{'standards': standards}
{'dokumente': dokumente}
)

14
k8s/helm-chart/Chart.yaml Normal file
View File

@@ -0,0 +1,14 @@
apiVersion: v2
name: vorgabenui
description: Helm chart for VorgabenUI Django application with Kroki diagram service
type: application
version: 0.1.0
appVersion: "0.939"
keywords:
- django
- kroki
- diagrams
- vorgabenui
maintainers:
- name: adebaumann
email: adebaumann@baumann.gr

View File

@@ -0,0 +1,51 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "vorgabenui.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "vorgabenui.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "vorgabenui.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "vorgabenui.labels" -}}
helm.sh/chart: {{ include "vorgabenui.chart" . }}
{{ include "vorgabenui.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "vorgabenui.selectorLabels" -}}
app.kubernetes.io/name: {{ include "vorgabenui.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@@ -0,0 +1,59 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "vorgabenui.fullname" . }}
namespace: {{ .Values.global.namespace }}
labels:
{{- include "vorgabenui.labels" . | nindent 4 }}
app.kubernetes.io/component: django
spec:
replicas: {{ .Values.django.replicaCount }}
selector:
matchLabels:
{{- include "vorgabenui.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: django
template:
metadata:
labels:
{{- include "vorgabenui.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: django
spec:
securityContext:
{{- toYaml .Values.django.securityContext | nindent 8 }}
initContainers:
- name: loader
image: "{{ .Values.django.dataLoader.image.repository }}:{{ .Values.django.dataLoader.image.tag }}"
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: "{{ .Values.django.image.repository }}:{{ .Values.django.image.tag }}"
imagePullPolicy: {{ .Values.django.image.pullPolicy }}
ports:
- containerPort: {{ .Values.django.service.port }}
volumeMounts:
- name: data
mountPath: /app/data
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "vorgabenui.fullname" . }}-data-pvc

View File

@@ -0,0 +1,17 @@
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "vorgabenui.fullname" . }}
namespace: {{ .Values.global.namespace }}
labels:
{{- include "vorgabenui.labels" . | nindent 4 }}
app.kubernetes.io/component: django
spec:
type: {{ .Values.django.service.type }}
selector:
{{- include "vorgabenui.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: django
ports:
- port: {{ .Values.django.service.port }}
targetPort: {{ .Values.django.service.port }}

View File

@@ -0,0 +1,33 @@
{{- if .Values.ingress.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "vorgabenui.fullname" . }}
namespace: {{ .Values.global.namespace }}
labels:
{{- include "vorgabenui.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: {{ .Values.ingress.path }}
pathType: {{ .Values.ingress.pathType }}
backend:
service:
name: {{ include "vorgabenui.fullname" . }}
port:
number: {{ .Values.django.service.port }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,48 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "vorgabenui.fullname" . }}-kroki
namespace: {{ .Values.global.namespace }}
labels:
{{- include "vorgabenui.labels" . | nindent 4 }}
app.kubernetes.io/component: kroki
spec:
replicas: {{ .Values.kroki.replicaCount }}
selector:
matchLabels:
{{- include "vorgabenui.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: kroki
template:
metadata:
labels:
{{- include "vorgabenui.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: kroki
spec:
containers:
- name: kroki
image: "{{ .Values.kroki.image.repository }}:{{ .Values.kroki.image.tag }}"
ports:
- containerPort: {{ .Values.kroki.service.port }}
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
{{- range $service := .Values.kroki.services }}
- name: {{ $service }}
image: "{{ $service.image.repository }}:{{ $service.image.tag }}"
ports:
- containerPort: {{ $service.port }}
{{- end }}

View File

@@ -0,0 +1,16 @@
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "vorgabenui.fullname" . }}-kroki
namespace: {{ .Values.global.namespace }}
labels:
{{- include "vorgabenui.labels" . | nindent 4 }}
app.kubernetes.io/component: kroki
spec:
selector:
{{- include "vorgabenui.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: kroki
ports:
- port: {{ .Values.kroki.service.port }}
targetPort: {{ .Values.kroki.service.port }}

View File

@@ -0,0 +1,19 @@
{{- if .Values.persistence.enabled }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "vorgabenui.fullname" . }}-data-pvc
namespace: {{ .Values.global.namespace }}
labels:
{{- include "vorgabenui.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,77 @@
# Global settings
global:
namespace: vorgabenui
# Django application settings
django:
replicaCount: 1
image:
repository: git.baumann.gr/adebaumann/vui
tag: "0.939"
pullPolicy: Always
# Data loader init container
dataLoader:
image:
repository: git.baumann.gr/adebaumann/vui-data-loader
tag: "0.8"
# Security context
securityContext:
fsGroup: 999
fsGroupChangePolicy: "OnRootMismatch"
# Service settings
service:
type: ClusterIP
port: 8000
# Kroki diagram service settings
kroki:
replicaCount: 1
# Main kroki service
image:
repository: git.baumann.gr/adebaumann/kroki
tag: "0.026"
# Additional diagram services
services:
mermaid:
image:
repository: git.baumann.gr/adebaumann/kroki-mermaid
tag: "0.026"
port: 8002
bpmn:
image:
repository: git.baumann.gr/adebaumann/kroki-bpmn
tag: "0.026"
port: 8003
excalidraw:
image:
repository: git.baumann.gr/adebaumann/kroki-excalidraw
tag: "0.026"
port: 8004
# Service settings
service:
port: 8000
# Persistent storage
persistence:
enabled: true
storageClass: ""
accessModes:
- ReadWriteOnce
size: 2Gi
# Ingress settings
ingress:
enabled: true
className: ""
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
host: vorgabenportal.knowyoursecurity.com
path: /
pathType: Prefix
tls: []

View File

@@ -16,7 +16,7 @@
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<a class="nav-item nav-link active" href="/standards">Standards</a>
<a class="nav-item nav-link active" href="/dokumente">Standards</a>
<a class="nav-item nav-link" href="/referenzen">Referenzen</a>
<a class="nav-item nav-link" href="/stichworte">Stichworte</a>
<a class="nav-item nav-link" href="/search">Suche</a>
@@ -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.930</div>
<div>VorgabenUI v0.939</div>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<h1>Vorgaben Informatiksicherheit BIT</h1>
<h2>Aktuell erfasste Standards</h2>
<ul>
{% for standard in standards %}
{% for standard in dokumente %}
<li><a href="{% url 'standard_detail' nummer=standard.nummer %}">{{ standard }}</a></li>
{% endfor %}
</ul>

View File

@@ -1,31 +1,31 @@
from django.shortcuts import render
from abschnitte.utils import render_textabschnitte
from standards.models import Dokument, VorgabeLangtext, VorgabeKurztext, Geltungsbereich
from dokumente.models import Dokument, VorgabeLangtext, VorgabeKurztext, Geltungsbereich
from itertools import groupby
import datetime
import pprint
def startseite(request):
standards=list(Dokument.objects.all())
return render(request, 'startseite.html', {"standards":standards,})
standards=list(Dokument.objects.filter(aktiv=True))
return render(request, 'startseite.html', {"dokumente":standards,})
def search(request):
if request.method == "GET":
return render(request, 'search.html')
elif request.method == "POST":
suchbegriff=request.POST.get("q")
areas=request.POST.getlist("suchbereich[]")
result= {"all": {}}
qs = VorgabeKurztext.objects.filter(inhalt__contains=suchbegriff).exclude(abschnitt__gueltigkeit_bis__lt=datetime.date.today())
result["kurztext"] = {k: [o.abschnitt for o in g] for k, g in groupby(qs, key=lambda o: o.abschnitt.dokument)}
qs = VorgabeLangtext.objects.filter(inhalt__contains=suchbegriff).exclude(abschnitt__gueltigkeit_bis__lt=datetime.date.today())
result['langtext']= {k: [o.abschnitt for o in g] for k, g in groupby(qs, key=lambda o: o.abschnitt.dokument)}
for r in result.keys():
for s in result[r].keys():
result["all"][s] = set(result[r][s])
result["geltungsbereich"]={}
geltungsbereich=set(list([x.geltungsbereich for x in Geltungsbereich.objects.filter(inhalt__contains=suchbegriff)]))
for s in geltungsbereich:
result["geltungsbereich"][s]=render_textabschnitte(s.geltungsbereich_set.order_by("order"))
for r in result.keys():
for s in result[r].keys():
result["all"][s] = set(result[r][s])
print (result)
pprint.pp (result)
return render(request,"results.html",{"suchbegriff":suchbegriff,"resultat":result})

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,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,14 +1,40 @@
/* 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;
margin-bottom: 50px;
background-color: #f9f9f9;
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;