diff --git a/.gitignore b/.gitignore index a272658..406784b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ keys/ .idea/ *.kate-swp + +# Diagram cache directory +media/diagram_cache/ diff --git a/Documentation/DIAGRAM_CACHING.md b/Documentation/DIAGRAM_CACHING.md new file mode 100644 index 0000000..4effbbf --- /dev/null +++ b/Documentation/DIAGRAM_CACHING.md @@ -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 diff --git a/abschnitte/management/__init__.py b/abschnitte/management/__init__.py new file mode 100644 index 0000000..2c1c7c1 --- /dev/null +++ b/abschnitte/management/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/abschnitte/management/commands/__init__.py b/abschnitte/management/commands/__init__.py new file mode 100644 index 0000000..b5a3a84 --- /dev/null +++ b/abschnitte/management/commands/__init__.py @@ -0,0 +1 @@ +# Commands package diff --git a/abschnitte/management/commands/clear_diagram_cache.py b/abschnitte/management/commands/clear_diagram_cache.py new file mode 100644 index 0000000..035b466 --- /dev/null +++ b/abschnitte/management/commands/clear_diagram_cache.py @@ -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')) diff --git a/abschnitte/utils.py b/abschnitte/utils.py index 6194727..6d13cf4 100644 --- a/abschnitte/utils.py +++ b/abschnitte/utils.py @@ -3,6 +3,10 @@ import base64 import zlib import re from textwrap import dedent +from django.conf import settings + +# Import the caching function +from diagramm_proxy.diagram_cache import get_cached_diagram DIAGRAMMSERVER="/diagramm" @@ -25,15 +29,23 @@ def render_textabschnitte(queryset): elif typ == "tabelle": html = md_table_to_html(inhalt) elif typ == "diagramm": - temp=inhalt.splitlines() - diagramtype=temp.pop(0) - diagramoptions='width="100%"' - if temp[0][0:6].lower() == "option": - diagramoptions=temp.pop(0).split(":",1)[1] - rest="\n".join(temp) - html = '

' + except Exception as e: + # Fallback to error message + html = f'

Error generating diagram: {str(e)}

' + elif typ == "code": html = "
"
             html += markdown(inhalt, extensions=['tables', 'attr_list'])
diff --git a/diagramm_proxy/__init__.py b/diagramm_proxy/__init__.py
new file mode 100644
index 0000000..30fabe8
--- /dev/null
+++ b/diagramm_proxy/__init__.py
@@ -0,0 +1 @@
+# Diagram proxy module
diff --git a/diagramm_proxy/diagram_cache.py b/diagramm_proxy/diagram_cache.py
new file mode 100644
index 0000000..49e3b76
--- /dev/null
+++ b/diagramm_proxy/diagram_cache.py
@@ -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}")