Compare commits
47 Commits
e84f25ca1d
...
improve-do
| Author | SHA1 | Date | |
|---|---|---|---|
| aca9a2f307 | |||
| 081ea4de1c | |||
| a075811173 | |||
| d4143da9fc | |||
| b0c9b89e94 | |||
|
|
94363d49ce | ||
|
|
8bca1bb3c7 | ||
|
|
1ce8eb15c0 | ||
|
|
4d2ffeea27 | ||
|
|
8860947d38 | ||
|
|
6df72c95cb | ||
| 2afada0bce | |||
|
|
a42a65b40f | ||
| 5609a735f4 | |||
| 6654779e67 | |||
| 7befde104d | |||
| 96819a7427 | |||
| a437af554b | |||
| 650fe0a87b | |||
|
|
ddf035c50f | ||
|
|
886baa163e | ||
|
|
1146506ca2 | ||
|
|
9610024739 | ||
|
|
c8755e4339 | ||
|
|
0bc1fe7413 | ||
|
|
8ce761c248 | ||
|
|
39a2021cc3 | ||
| 957a1b9255 | |||
| afc07d4561 | |||
| af06598172 | |||
| 4213ca60ac | |||
| bf2f15fa5c | |||
| c1eb2d7871 | |||
| b29e894b22 | |||
| 0f096d18aa | |||
| 9b484787a4 | |||
| 8dd3b4e9af | |||
| 0d0199ca62 | |||
| 5f58d660c0 | |||
| 67c393ecf1 | |||
| dbb3ecd5bf | |||
| 966cd46228 | |||
| 1ee9b3c46f | |||
| 8f57f5fc5b | |||
| cd7195b3aa | |||
| 020dff0871 | |||
| 1dbdbc7f3c |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,3 +10,8 @@ keys/
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
*.kate-swp
|
*.kate-swp
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
package.json
|
||||||
|
# Diagram cache directory
|
||||||
|
media/diagram_cache/
|
||||||
|
|||||||
105
Documentation/DIAGRAM_CACHING.md
Normal file
105
Documentation/DIAGRAM_CACHING.md
Normal 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
|
||||||
@@ -126,6 +126,13 @@ STATICFILES_DIRS= (
|
|||||||
os.path.join(BASE_DIR,"static"),
|
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
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ STATICFILES_DIRS= (
|
|||||||
os.path.join(BASE_DIR,"static"),
|
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
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
|||||||
@@ -34,5 +34,11 @@ urlpatterns = [
|
|||||||
path('referenzen/', referenzen.views.tree, name="referenz_tree"),
|
path('referenzen/', referenzen.views.tree, name="referenz_tree"),
|
||||||
path('referenzen/<str:refid>/', referenzen.views.detail, name="referenz_detail"),
|
path('referenzen/<str:refid>/', referenzen.views.detail, name="referenz_detail"),
|
||||||
re_path(r'^diagramm/(?P<path>.*)$', DiagrammProxyView.as_view()),
|
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)
|
||||||
|
|||||||
1
abschnitte/management/__init__.py
Normal file
1
abschnitte/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Management commands
|
||||||
1
abschnitte/management/commands/__init__.py
Normal file
1
abschnitte/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Commands package
|
||||||
23
abschnitte/management/commands/clear_diagram_cache.py
Normal file
23
abschnitte/management/commands/clear_diagram_cache.py
Normal 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'))
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import base64
|
|||||||
import zlib
|
import zlib
|
||||||
import re
|
import re
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Import the caching function
|
||||||
|
from diagramm_proxy.diagram_cache import get_cached_diagram
|
||||||
|
|
||||||
DIAGRAMMSERVER="/diagramm"
|
DIAGRAMMSERVER="/diagramm"
|
||||||
|
|
||||||
@@ -25,15 +29,23 @@ def render_textabschnitte(queryset):
|
|||||||
elif typ == "tabelle":
|
elif typ == "tabelle":
|
||||||
html = md_table_to_html(inhalt)
|
html = md_table_to_html(inhalt)
|
||||||
elif typ == "diagramm":
|
elif typ == "diagramm":
|
||||||
temp=inhalt.splitlines()
|
temp = inhalt.splitlines()
|
||||||
diagramtype=temp.pop(0)
|
diagramtype = temp.pop(0)
|
||||||
diagramoptions='width="100%"'
|
diagramoptions = 'width="100%"'
|
||||||
if temp[0][0:6].lower() == "option":
|
if temp and temp[0][0:6].lower() == "option":
|
||||||
diagramoptions=temp.pop(0).split(":",1)[1]
|
diagramoptions = temp.pop(0).split(":", 1)[1]
|
||||||
rest="\n".join(temp)
|
rest = "\n".join(temp)
|
||||||
html = '<p><img '+diagramoptions+' src="'+DIAGRAMMSERVER+"/"+diagramtype+"/svg/"
|
|
||||||
html += base64.urlsafe_b64encode(zlib.compress(rest.encode("utf-8"),9)).decode()
|
# Use caching instead of URL encoding
|
||||||
html += '"></p>'
|
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":
|
elif typ == "code":
|
||||||
html = "<pre><code>"
|
html = "<pre><code>"
|
||||||
html += markdown(inhalt, extensions=['tables', 'attr_list'])
|
html += markdown(inhalt, extensions=['tables', 'attr_list'])
|
||||||
|
|||||||
102
admin/css/vorgabe_border.css
Normal file
102
admin/css/vorgabe_border.css
Normal 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;
|
||||||
|
}
|
||||||
25
admin/js/vorgabe_toggle.js
Normal file
25
admin/js/vorgabe_toggle.js
Normal 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);
|
||||||
@@ -18,20 +18,36 @@ spec:
|
|||||||
fsGroupChangePolicy: "OnRootMismatch"
|
fsGroupChangePolicy: "OnRootMismatch"
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: loader
|
- name: loader
|
||||||
image: git.baumann.gr/adebaumann/vui-data-loader:0.7
|
image: git.baumann.gr/adebaumann/vui-data-loader:0.8
|
||||||
command: [ "sh","-c","cp preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ]
|
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ]
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/vui:0.931
|
image: git.baumann.gr/adebaumann/vui:0.939
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /app/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:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
Binary file not shown.
BIN
data/db.sqlite3
BIN
data/db.sqlite3
Binary file not shown.
1
diagramm_proxy/__init__.py
Normal file
1
diagramm_proxy/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Diagram proxy module
|
||||||
91
diagramm_proxy/diagram_cache.py
Normal file
91
diagramm_proxy/diagram_cache.py
Normal 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}")
|
||||||
@@ -4,6 +4,7 @@ from nested_admin import NestedStackedInline, NestedModelAdmin, NestedTabularInl
|
|||||||
from django import forms
|
from django import forms
|
||||||
from mptt.forms import TreeNodeMultipleChoiceField
|
from mptt.forms import TreeNodeMultipleChoiceField
|
||||||
from mptt.admin import DraggableMPTTAdmin
|
from mptt.admin import DraggableMPTTAdmin
|
||||||
|
from adminsortable2.admin import SortableInlineAdminMixin, SortableAdminBase
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
from .models import *
|
from .models import *
|
||||||
@@ -20,21 +21,33 @@ from referenzen.models import Referenz
|
|||||||
# 'frage': forms.Textarea(attrs={'rows': 1, 'cols': 100}),
|
# 'frage': forms.Textarea(attrs={'rows': 1, 'cols': 100}),
|
||||||
# }
|
# }
|
||||||
|
|
||||||
class ChecklistenfragenInline(NestedTabularInline):
|
class ChecklistenfragenInline(NestedStackedInline):
|
||||||
model=Checklistenfrage
|
model=Checklistenfrage
|
||||||
extra=0
|
extra=0
|
||||||
fk_name="vorgabe"
|
fk_name="vorgabe"
|
||||||
# form=ChecklistenForm
|
|
||||||
classes = ['collapse']
|
classes = ['collapse']
|
||||||
|
verbose_name_plural = "Checklistenfragen"
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('frage',),
|
||||||
|
'classes': ('wide',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VorgabeKurztextInline(NestedTabularInline):
|
class VorgabeKurztextInline(NestedStackedInline):
|
||||||
model=VorgabeKurztext
|
model=VorgabeKurztext
|
||||||
extra=0
|
extra=0
|
||||||
sortable_field_name = "order"
|
sortable_field_name = "order"
|
||||||
show_change_link=True
|
show_change_link=True
|
||||||
classes = ['collapse']
|
classes = ['collapse']
|
||||||
#inline=inhalt
|
verbose_name_plural = "Kurztext-Abschnitte"
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('abschnitttyp', 'inhalt', 'order'),
|
||||||
|
'classes': ('wide',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
class VorgabeLangtextInline(NestedStackedInline):
|
class VorgabeLangtextInline(NestedStackedInline):
|
||||||
model=VorgabeLangtext
|
model=VorgabeLangtext
|
||||||
@@ -42,42 +55,75 @@ class VorgabeLangtextInline(NestedStackedInline):
|
|||||||
sortable_field_name = "order"
|
sortable_field_name = "order"
|
||||||
show_change_link=True
|
show_change_link=True
|
||||||
classes = ['collapse']
|
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
|
model=Geltungsbereich
|
||||||
extra=0
|
extra=0
|
||||||
sortable_field_name = "order"
|
sortable_field_name = "order"
|
||||||
show_change_link=True
|
show_change_link=True
|
||||||
classes = ['collapse']
|
classes = ['collapse']
|
||||||
classes = ['collapse']
|
verbose_name_plural = "Geltungsbereich-Abschnitte"
|
||||||
#inline=inhalt
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('abschnitttyp', 'inhalt', 'order'),
|
||||||
|
'classes': ('wide',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
class EinleitungInline(NestedTabularInline):
|
class EinleitungInline(NestedStackedInline):
|
||||||
model = Einleitung
|
model = Einleitung
|
||||||
extra = 0
|
extra = 0
|
||||||
sortable_field_name = "order"
|
sortable_field_name = "order"
|
||||||
show_change_link = True
|
show_change_link = True
|
||||||
classes = ['collapse']
|
classes = ['collapse']
|
||||||
|
verbose_name_plural = "Einleitungs-Abschnitte"
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('abschnitttyp', 'inhalt', 'order'),
|
||||||
|
'classes': ('wide',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
class VorgabeForm(forms.ModelForm):
|
class VorgabeForm(forms.ModelForm):
|
||||||
# referenzen = TreeNodeMultipleChoiceField(queryset=Referenz.objects.all(), required=False)
|
referenzen = TreeNodeMultipleChoiceField(queryset=Referenz.objects.all(), required=False)
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Vorgabe
|
model = Vorgabe
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
class VorgabeInline(NestedTabularInline): # or StackedInline for more vertical layout
|
class VorgabeInline(SortableInlineAdminMixin, NestedStackedInline):
|
||||||
model = Vorgabe
|
model = Vorgabe
|
||||||
form = VorgabeForm
|
form = VorgabeForm
|
||||||
extra = 0
|
extra = 0
|
||||||
#show_change_link = True
|
sortable_field_name = "order"
|
||||||
inlines = [VorgabeKurztextInline,VorgabeLangtextInline,ChecklistenfragenInline]
|
show_change_link = False
|
||||||
|
can_delete = False
|
||||||
|
inlines = [VorgabeKurztextInline, VorgabeLangtextInline, ChecklistenfragenInline]
|
||||||
autocomplete_fields = ['stichworte','referenzen','relevanz']
|
autocomplete_fields = ['stichworte','referenzen','relevanz']
|
||||||
#search_fields=['nummer','name']ModelAdmin.
|
# Remove collapse class so Vorgaben show by default
|
||||||
list_filter=['stichworte']
|
|
||||||
#classes=["collapse"]
|
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(NestedStackedInline):
|
class StichworterklaerungInline(NestedTabularInline):
|
||||||
model=Stichworterklaerung
|
model=Stichworterklaerung
|
||||||
extra=0
|
extra=0
|
||||||
sortable_field_name = "order"
|
sortable_field_name = "order"
|
||||||
@@ -100,28 +146,71 @@ class PersonAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Dokument)
|
@admin.register(Dokument)
|
||||||
class DokumentAdmin(NestedModelAdmin):
|
class DokumentAdmin(SortableAdminBase, NestedModelAdmin):
|
||||||
actions_on_top=True
|
actions_on_top=True
|
||||||
inlines = [EinleitungInline,GeltungsbereichInline,VorgabeInline]
|
inlines = [EinleitungInline, GeltungsbereichInline, VorgabeInline]
|
||||||
#filter_horizontal=['autoren','pruefende']
|
filter_horizontal=['autoren','pruefende']
|
||||||
list_display=['nummer','name','dokumententyp']
|
list_display=['nummer','name','dokumententyp','gueltigkeit_von','gueltigkeit_bis','aktiv']
|
||||||
search_fields=['nummer','name']
|
search_fields=['nummer','name']
|
||||||
|
list_filter=['dokumententyp','aktiv','gueltigkeit_von']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Grunddaten', {
|
||||||
|
'fields': ('nummer', 'name', 'dokumententyp', 'aktiv'),
|
||||||
|
'classes': ('wide',),
|
||||||
|
}),
|
||||||
|
('Verantwortlichkeiten', {
|
||||||
|
'fields': ('autoren', 'pruefende'),
|
||||||
|
'classes': ('wide', 'collapse'),
|
||||||
|
}),
|
||||||
|
('Gültigkeit & Metadaten', {
|
||||||
|
'fields': ('gueltigkeit_von', 'gueltigkeit_bis', 'signatur_cso', 'anhaenge'),
|
||||||
|
'classes': ('wide', 'collapse'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
# js = ('admin/js/vorgabe_collapse.js',)
|
js = ('admin/js/vorgabe_collapse.js',)
|
||||||
css = {
|
css = {
|
||||||
'all': ('admin/css/vorgabe_border.css',
|
'all': ('admin/css/vorgabe_border.css',)
|
||||||
# 'admin/css/vorgabe_collapse.css',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#admin.site.register(Stichwort)
|
#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(Checklistenfrage)
|
||||||
admin.site.register(Dokumententyp)
|
admin.site.register(Dokumententyp)
|
||||||
#admin.site.register(Person)
|
#admin.site.register(Person)
|
||||||
admin.site.register(Thema)
|
|
||||||
#admin.site.register(Referenz, DraggableM§PTTAdmin)
|
#admin.site.register(Referenz, DraggableM§PTTAdmin)
|
||||||
admin.site.register(Vorgabe)
|
admin.site.register(Vorgabe)
|
||||||
|
|
||||||
#admin.site.register(Changelog)
|
#admin.site.register(Changelog)
|
||||||
|
|||||||
19
dokumente/migrations/0008_dokument_aktiv.py
Normal file
19
dokumente/migrations/0008_dokument_aktiv.py
Normal 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -47,6 +47,7 @@ class Dokument(models.Model):
|
|||||||
gueltigkeit_bis = models.DateField(null=True, blank=True)
|
gueltigkeit_bis = models.DateField(null=True, blank=True)
|
||||||
signatur_cso = models.CharField(max_length=255, blank=True)
|
signatur_cso = models.CharField(max_length=255, blank=True)
|
||||||
anhaenge = models.TextField(blank=True)
|
anhaenge = models.TextField(blank=True)
|
||||||
|
aktiv = models.BooleanField(blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.nummer} – {self.name}"
|
return f"{self.nummer} – {self.name}"
|
||||||
@@ -56,6 +57,7 @@ class Dokument(models.Model):
|
|||||||
verbose_name="Dokument"
|
verbose_name="Dokument"
|
||||||
|
|
||||||
class Vorgabe(models.Model):
|
class Vorgabe(models.Model):
|
||||||
|
order = models.IntegerField()
|
||||||
nummer = models.IntegerField()
|
nummer = models.IntegerField()
|
||||||
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='vorgaben')
|
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='vorgaben')
|
||||||
thema = models.ForeignKey(Thema, on_delete=models.PROTECT)
|
thema = models.ForeignKey(Thema, on_delete=models.PROTECT)
|
||||||
@@ -86,7 +88,7 @@ class Vorgabe(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural="Vorgaben"
|
verbose_name_plural="Vorgaben"
|
||||||
|
ordering = ['order']
|
||||||
|
|
||||||
class VorgabeLangtext(Textabschnitt):
|
class VorgabeLangtext(Textabschnitt):
|
||||||
abschnitt=models.ForeignKey(Vorgabe,on_delete=models.CASCADE)
|
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_plural="Fragen für Checkliste"
|
||||||
verbose_name="Frage 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):
|
class Changelog(models.Model):
|
||||||
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='changelog')
|
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='changelog')
|
||||||
autoren = models.ManyToManyField(Person)
|
autoren = models.ManyToManyField(Person)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Autoren, Prüfende etc. -->
|
<!-- Autoren, Prüfende etc. -->
|
||||||
<p><strong>Autoren:</strong> {{ standard.autoren.all|join:", " }}</p>
|
<p><strong>Autoren:</strong> {{ standard.autoren.all|join:", " }}</p>
|
||||||
<p><strong>Prüfende:</strong> {{ standard.pruefende.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 -->
|
<!-- Start Einleitung -->
|
||||||
{% if standard.einleitung_html %}
|
{% if standard.einleitung_html %}
|
||||||
|
|||||||
@@ -1,8 +1,515 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase, Client
|
||||||
from myapp.models import Dokument
|
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
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
|
|
||||||
class DokumentTestCase (TestCase):
|
class DokumententypModelTest(TestCase):
|
||||||
|
"""Test cases for Dokumententyp model"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
Document.objects.create(name)
|
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/')
|
||||||
|
|||||||
@@ -28,6 +28,6 @@
|
|||||||
<div class="flex-fill">{% block content %}Main Content{% endblock %}</div>
|
<div class="flex-fill">{% block content %}Main Content{% endblock %}</div>
|
||||||
<div class="col-md-2">{% block sidebar_right %}{% endblock %}</div>
|
<div class="col-md-2">{% block sidebar_right %}{% endblock %}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>VorgabenUI v0.931</div>
|
<div>VorgabenUI v0.939</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import datetime
|
|||||||
import pprint
|
import pprint
|
||||||
|
|
||||||
def startseite(request):
|
def startseite(request):
|
||||||
standards=list(Dokument.objects.all())
|
standards=list(Dokument.objects.filter(aktiv=True))
|
||||||
return render(request, 'startseite.html', {"dokumente":standards,})
|
return render(request, 'startseite.html', {"dokumente":standards,})
|
||||||
|
|
||||||
def search(request):
|
def search(request):
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ class ReferenzerklaerungInline(NestedStackedInline):
|
|||||||
class ReferenzAdmin(NestedModelAdmin):
|
class ReferenzAdmin(NestedModelAdmin):
|
||||||
inlines=[ReferenzerklaerungInline]
|
inlines=[ReferenzerklaerungInline]
|
||||||
list_display =['Path']
|
list_display =['Path']
|
||||||
search_fields=("referenz",)
|
search_fields=("referenz","path")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ charset-normalizer==3.4.3
|
|||||||
curtsies==0.4.3
|
curtsies==0.4.3
|
||||||
cwcwidth==0.1.10
|
cwcwidth==0.1.10
|
||||||
Django==5.2.5
|
Django==5.2.5
|
||||||
|
django-admin-sortable2==2.2.8
|
||||||
django-js-asset==3.1.2
|
django-js-asset==3.1.2
|
||||||
django-mptt==0.17.0
|
django-mptt==0.17.0
|
||||||
django-mptt-admin==2.8.0
|
django-mptt-admin==2.8.0
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
/* Style each Vorgabe inline block */
|
/* Style each Vorgabe inline block */
|
||||||
.djn-dynamic-form-Standards-vorgabe {
|
.djn-dynamic-form-Standards-vorgabe,
|
||||||
border: 2px solid #ccc;
|
.djn-dynamic-form-dokumente-vorgabe {
|
||||||
|
border: 3px solid #2c5aa0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 15px;
|
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) */
|
/* 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;
|
margin-top: 10px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
border-left: 2px dashed #ccc;
|
border-left: 2px dashed #ccc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,58 @@
|
|||||||
window.addEventListener('load', function () {
|
window.addEventListener('load', function () {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const vorgabenBlocks = document.querySelectorAll('.djn-dynamic-form-Standards-vorgabe');
|
// Try different selectors for nested admin vorgabe elements
|
||||||
console.log("Found", vorgabenBlocks.length, "Vorgaben blocks");
|
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) => {
|
vorgabenBlocks.forEach((block, index) => {
|
||||||
const header = document.createElement('div');
|
// Find the existing title/header within the vorgabe block
|
||||||
header.className = 'vorgabe-toggle-header';
|
const existingHeader = block.querySelector('h3, .inline-label, .module h2, .djn-inline-header');
|
||||||
header.innerHTML = `▼ Vorgabe ${index + 1}`;
|
|
||||||
header.style.cursor = 'pointer';
|
if (existingHeader) {
|
||||||
|
// Make the existing header clickable for collapse/expand
|
||||||
block.parentNode.insertBefore(header, block);
|
existingHeader.style.cursor = 'pointer';
|
||||||
|
existingHeader.addEventListener('click', (e) => {
|
||||||
header.addEventListener('click', () => {
|
e.preventDefault();
|
||||||
const isHidden = block.style.display === 'none';
|
e.stopPropagation();
|
||||||
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}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add initial collapse indicator
|
||||||
|
const originalText = existingHeader.textContent;
|
||||||
|
existingHeader.innerHTML = `▼ ${originalText}`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, 500); // wait 500ms to allow nested inlines to render
|
}, 1000); // wait longer to allow nested inlines to render
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user