Compare commits

...

62 Commits

Author SHA1 Message Date
362f474e3d Testing build on demand 2025-11-06 23:09:42 +01:00
b8069802f6 Debug print removed from dokumente/test.py 2025-11-06 16:04:44 +01:00
26d62014c9 Merge pull request 'feature/add-missing-tests' (#8) from feature/add-missing-tests into development
Reviewed-on: #8
2025-11-06 14:34:54 +00:00
69ca9bce4d Merge branch 'development' into feature/add-missing-tests 2025-11-06 14:34:44 +00:00
733a437ae0 Add comprehensive JSON generation tests and update documentation
- Add 9 new JSON export tests in dokumente/test_json.py
- Add 9 JSON tests to main dokumente/tests.py
- Fix Geltungsbereich field name issues in test setup
- Update test documentation with JSON test coverage
- Update test counts: Total 206 tests (was 188)
- JSON tests cover both management command and view functionality
- Tests include file output, stdout, error handling, and edge cases
- All 206 tests now passing
2025-11-06 14:36:04 +01:00
277a24bb50 Add comprehensive test suites and documentation
- Add complete test coverage for referenzen, rollen, and stichworte apps
- Implement 54 new tests covering models, relationships, and business logic
- Fix MPTT method names and import issues in test implementations
- Create comprehensive test documentation in English and German
- All 188 tests now passing across all Django apps

Test coverage breakdown:
- referenzen: 18 tests (MPTT hierarchy, model validation)
- rollen: 18 tests (role models, relationships)
- stichworte: 18 tests (keyword models, ordering)
- Total: 54 new tests added

Documentation:
- Test suite.md: Complete English documentation
- Test Suite-DE.md: Complete German documentation
2025-11-06 14:07:54 +01:00
4b15c5f173 Merge pull request 'Better 'Stichwort' admin pages' (#7) from improvement/admin-tweaks into development
Reviewed-on: #7
2025-11-06 12:10:52 +00:00
b8d5bc796d Merge branch 'cleanup/remove-diagram-proxy' into development 2025-11-06 13:02:13 +01:00
9bd4cb19d3 Deploy 945 2025-11-05 14:52:31 +01:00
28f87509d6 Removed diagram proxy - no longer needed because of cacheing function 2025-11-05 14:46:03 +01:00
9d4c7d5f87 Cleanup in Dockerfile 2025-11-05 13:41:29 +01:00
f7e6795c00 Deploy 944 2025-11-05 12:22:13 +01:00
8520412867 Better 'Stichwort' admin pages 2025-11-05 12:18:59 +01:00
e94f61a697 Deploy 943 2025-11-05 11:16:28 +01:00
0cd09d0878 .env ignored 2025-11-04 17:01:49 +01:00
994ba5d797 Merge pull request 'feature/json' (#6) from feature/json into development
Reviewed-on: #6
2025-11-04 15:58:34 +00:00
af636fe6ea JSON functionality extended to website. Tests pending. 2025-11-04 16:07:29 +01:00
3ccb32e8e1 feat: add comprehensive JSON export command for dokumente
- Add Django management command 'export_json' for exporting all dokumente data
- Implement structured JSON format with proper section types from database
- Include all document fields: gueltigkeit, signatur_cso, anhaenge, changelog
- Support Kurztext, Geltungsbereich, Einleitung with Langtext-style structure
- Use actual abschnitttyp values instead of hardcoded 'text'
- Handle Referenz model fields correctly (name_nummer, name_text)
- Support --output parameter for file export or stdout by default
2025-11-04 15:56:54 +01:00
af4e1c61aa Added early JSON file for reference 2025-11-04 14:45:00 +00:00
8153aa56ce Merge pull request 'feat: enhance incomplete Vorgaben page with table layout and admin integration' (#4) from feature/list-of-incomplete-vorgaben into development
Reviewed-on: #4
2025-11-04 13:58:40 +00:00
b82c6fea38 Merge branch 'development' into feature/list-of-incomplete-vorgaben 2025-11-04 13:58:26 +00:00
cb374bfa77 feat: enhance incomplete Vorgaben page with table layout and admin integration
- Redesign incomplete Vorgaben page from card layout to unified table format
- Add visual status indicators (✓/✗) for each completeness category
- Link Vorgaben directly to admin edit pages (/autorenumgebung/ instead of /admin/)
- Enhance Vorgabe admin with Kurztext and Langtext inlines for complete editing
- Update all tests to work with new table structure and admin URLs
- Add JavaScript for dynamic summary count updates
- Maintain staff-only access control and responsive design

All 112 tests passing successfully.
2025-11-04 14:52:41 +01:00
2b41490806 Tests corrected, 'Thema' is now required (produces errors otherwise) 2025-11-04 14:35:55 +01:00
7186fa2cbe Deploy 942 2025-11-04 13:31:58 +01:00
da1deac44e Unvollständige Vorgaben nur noch für Admins 2025-11-04 13:25:27 +01:00
faae37e6ae Fixed tests - expecting English and getting German, now expect German 2025-11-04 13:19:27 +01:00
6aefb046b6 feat: incomplete Vorgaben page implementation
## New Incomplete Vorgaben Page
- Created new incomplete_vorgaben view in dokumente/views.py
- Added URL pattern /dokumente/unvollstaendig/ in dokumente/urls.py
- Built responsive Bootstrap template showing 4 categories of incomplete Vorgaben:
  1. Vorgaben without references
  2. Vorgaben without Stichworte
  3. Vorgaben without Kurz- or Langtext
  4. Vorgaben without Checklistenfragen
- Added navigation link "Unvollständig" to main menu
- Created comprehensive test suite with 14 test cases covering all functionality
- All incomplete Vorgaben tests now passing (14/14)

## Bug Fixes and Improvements
- Fixed model field usage: corrected Referenz model field names (name_nummer, url)
- Fixed test logic: corrected test expectations and data setup for accurate validation
- Fixed template styling: made badge styling consistent across all sections
- Removed debug output: cleaned up print statements for production readiness
- Enhanced test data creation to use correct model field names

## Test Coverage
- Total tests: 41/41 passing
- Search functionality: 27 tests covering validation, security, case-insensitivity, and content types
- Incomplete Vorgaben: 14 tests covering page functionality, data categorization, and edge cases
- Both features are fully tested and production-ready

## Security Enhancements
- Input validation prevents SQL injection attempts
- HTML escaping prevents XSS attacks in search results
- Length validation prevents buffer overflow attempts
- Character validation ensures only appropriate input is processed

The application now provides robust search capabilities with comprehensive security measures and a valuable content management tool for identifying incomplete Vorgaben entries.
2025-11-04 13:15:51 +01:00
2350cca32c Enhance search functionality with case-insensitive title search, security improvements, and comprehensive tests
- Add case-insensitive search across all fields (inhalt, titel, geltungsbereich)
- Include Vorgabe.titel field in search scope for better coverage
- Implement comprehensive input validation against SQL injection and XSS
- Add German error messages for validation failures
- Escape search terms in templates to prevent XSS attacks
- Add input length limits and character validation
- Preserve user input on validation errors for better UX
- Add comprehensive test suite with 27 tests covering all functionality
- Test security features: XSS prevention, SQL injection protection, input validation
- Test edge cases: expired content, multiple documents, German umlauts
- Ensure all search fields work correctly with case-insensitive matching
2025-11-04 13:00:02 +01:00
671d259c44 Enhance search functionality with case-insensitive title search and security improvements
- Add case-insensitive search across all fields (inhalt, titel, geltungsbereich)
- Include Vorgabe.titel field in search scope for better coverage
- Implement comprehensive input validation against SQL injection and XSS
- Add German error messages for validation failures
- Escape search terms in templates to prevent XSS attacks
- Add input length limits and character validation
- Preserve user input on validation errors for better UX
2025-11-04 12:54:44 +01:00
28a1bb4b62 Translated 'rogue' English error message 2025-11-04 11:21:04 +01:00
898e9b8163 Merge branch 'feature/sanitychecks' into development 2025-11-04 09:07:37 +01:00
48bf8526b9 Deploy 941 - new database 2025-11-04 09:06:04 +01:00
7e4d2fa29b Changed edge case in date validation for Vorgaben 2025-11-03 13:21:47 +01:00
779604750e Add Vorgaben sanity check functionality
Implement comprehensive validation system to detect conflicting Vorgaben with overlapping validity periods.

Features:
- Static method Vorgabe.sanity_check_vorgaben() for global conflict detection
- Instance method Vorgabe.find_conflicts() for individual conflict checking
- Model validation via Vorgabe.clean() to prevent conflicting data
- Utility functions for date range intersection and conflict reporting
- Django management command 'sanity_check_vorgaben' for manual checks
- Comprehensive test suite with 17 new tests covering all functionality

Validation logic ensures Vorgaben with same dokument, thema, and nummer cannot have overlapping gueltigkeit_von/gueltigkeit_bis date ranges. Handles open-ended ranges (None end dates) and provides clear error messages.

Files added/modified:
- dokumente/models.py: Added sanity check methods and validation
- dokumente/utils.py: New utility functions for conflict detection
- dokumente/management/commands/sanity_check_vorgaben.py: New management command
- dokumente/tests.py: Added comprehensive test coverage
- test_sanity_check.py: Standalone test script

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,142 @@
name: Build image when workload image tag changes
on:
push:
branches: [ deployment ] # adjust if needed
paths:
- "arcocd/deployment.yaml"
- "Dockerfile" # keep if you also want to rebuild when Dockerfile changes
jobs:
build-if-image-changed:
runs-on: ubuntu-latest
env:
DEPLOY_FILE: "arcocd/deployment.yaml"
TARGET_REPO: "git.baumann.gr/adebaumann/vui" # repo (no tag)
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Determine base commit
id: base
shell: bash
run: |
set -euo pipefail
if git rev-parse --verify -q HEAD~1 >/dev/null; then
echo "base=$(git rev-parse HEAD~1)" >> "$GITHUB_OUTPUT"
else
echo "base=$(git hash-object -t tree /dev/null)" >> "$GITHUB_OUTPUT"
fi
# Install yq for robust YAML parsing
- name: Install yq
shell: bash
run: |
set -euo pipefail
YQ_VER=v4.44.3
curl -sL "https://github.com/mikefarah/yq/releases/download/${YQ_VER}/yq_linux_amd64" -o /usr/local/bin/yq
chmod +x /usr/local/bin/yq
yq --version
- name: Read workload image from deployment (old vs new)
id: img
shell: bash
run: |
set -euo pipefail
file="$DEPLOY_FILE"
repo="$TARGET_REPO"
# Function: from a deployment yaml, read .spec.template.spec.containers[].image
# and select the one whose image starts with "$repo:"
extract() {
yq -r '
.spec.template.spec.containers // [] # only real containers, not initContainers
| map(.image) | .[]? # images as strings
| select(startswith(env(repo) + ":")) # match exact repo + ":"
' "$1" 2>/dev/null | tail -n 1
}
# Old image from previous commit (if file existed)
if git cat-file -e "${{ steps.base.outputs.base }}":"$file" 2>/dev/null; then
git show "${{ steps.base.outputs.base }}:$file" > /tmp/old.yaml
old_image="$(extract /tmp/old.yaml || true)"
else
old_image=""
fi
# New image from workspace
if [ -f "$file" ]; then
new_image="$(extract "$file" || true)"
else
new_image=""
fi
echo "Old workload image: $old_image"
echo "New workload image: $new_image"
# Helpers to split repo and tag (handles registry with port)
parse_tag() {
local ref="$1"
local after_slash="${ref##*/}"
if [[ "$after_slash" == *:* ]]; then echo "${after_slash##*:}"; else echo ""; fi
}
parse_repo() {
local ref="$1"
local tag="$(parse_tag "$ref")"
if [ -n "$tag" ]; then echo "${ref%:$tag}"; else echo "$ref"; fi
}
old_tag="$(parse_tag "$old_image")"
new_tag="$(parse_tag "$new_image")"
new_repo="$(parse_repo "$new_image")"
if [ -z "$new_image" ]; then
echo "ERROR: Could not find a containers[].image starting with ${repo}: in $file"
exit 1
fi
registry="$(echo "$new_repo" | awk -F/ '{print $1}')"
{
echo "changed=$([ "$old_tag" != "$new_tag" ] && echo true || echo false)"
echo "new_image=$new_image"
echo "new_repo=$new_repo"
echo "new_tag=$new_tag"
echo "registry=$registry"
} >> "$GITHUB_OUTPUT"
- name: Skip if tag unchanged
if: steps.img.outputs.changed != 'true'
run: echo "Workload image tag unchanged in ${{ env.DEPLOY_FILE }}; skipping build."
- name: Set up Buildx
if: steps.img.outputs.changed == 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to registry
if: steps.img.outputs.changed == 'true'
uses: docker/login-action@v3
with:
registry: ${{ steps.img.outputs.registry }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push (exact tag from deployment)
if: steps.img.outputs.changed == 'true'
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.img.outputs.new_image }}
${{ steps.img.outputs.new_repo }}:latest
labels: |
org.opencontainers.image.source=${{ gitea.repository }}
org.opencontainers.image.revision=${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

7
.gitignore vendored
View File

@@ -8,8 +8,11 @@ include/
keys/
.venv/
.idea/
*.kate-swp
node_modules/
package-lock.json
package.json
# Diagram cache directory
media/diagram_cache/
.env
data/db.sqlite3

View File

@@ -28,7 +28,10 @@ RUN rm -rf /app/Dockerfile* \
/app/k8s \
/app/data-loader \
/app/keys \
/app/requirements.txt
/app/requirements.txt \
/app/node_modules \
/app/*.json \
/app/test_*.py
RUN python3 manage.py collectstatic
CMD ["gunicorn","--bind","0.0.0.0:8000","--workers","3","VorgabenUI.wsgi:application"]

1599
R0066.json Normal file

File diff suppressed because it is too large Load Diff

369
Test Suite-DE.md Normal file
View File

@@ -0,0 +1,369 @@
# Test-Suite Dokumentation
Dieses Dokument bietet einen umfassenden Überblick über alle Tests im vgui-cicd Django-Projekt und beschreibt, was jeder Test tut und wie er funktioniert.
## Inhaltsverzeichnis
- [abschnitte App Tests](#abschnitte-app-tests)
- [dokumente App Tests](#dokumente-app-tests)
- [pages App Tests](#pages-app-tests)
- [referenzen App Tests](#referenzen-app-tests)
- [rollen App Tests](#rollen-app-tests)
- [stichworte App Tests](#stichworte-app-tests)
---
## abschnitte App Tests
Die abschnitte App enthält 32 Tests, die Modelle, Utility-Funktionen, Diagram-Caching und Management-Befehle abdecken.
### Modell-Tests
#### AbschnittTypModelTest
- **test_abschnitttyp_creation**: Überprüft, dass AbschnittTyp-Objekte korrekt mit den erwarteten Feldwerten erstellt werden
- **test_abschnitttyp_primary_key**: Bestätigt, dass das `abschnitttyp`-Feld als Primärschlüssel dient
- **test_abschnitttyp_str**: Testet die String-Repräsentation, die den `abschnitttyp`-Wert zurückgibt
- **test_abschnitttyp_verbose_name_plural**: Validiert den korrekt gesetzten verbose_name_plural
- **test_create_multiple_abschnitttypen**: Stellt sicher, dass mehrere AbschnittTyp-Objekte mit verschiedenen Typen erstellt werden können
#### TextabschnittModelTest
- **test_textabschnitt_creation**: Testet, dass Textabschnitt über das konkrete Modell instanziiert werden kann
- **test_textabschnitt_default_order**: Überprüft, dass das `order`-Feld standardmäßig 0 ist
- **test_textabschnitt_ordering**: Testet, dass Textabschnitt-Objekte nach dem `order`-Feld sortiert werden können
- **test_textabschnitt_blank_fields**: Bestätigt, dass `abschnitttyp`- und `inhalt`-Felder leer/null sein können
- **test_textabschnitt_foreign_key_protection**: Testet, dass AbschnittTyp-Objekte vor Löschung geschützt sind, wenn sie von Textabschnitt referenziert werden
### Utility-Funktions-Tests
#### MdTableToHtmlTest
- **test_simple_table**: Konvertiert eine einfache Markdown-Tabelle mit Überschriften und einer Zeile nach HTML
- **test_table_with_multiple_rows**: Testet die Konvertierung von Tabellen mit mehreren Datenzeilen
- **test_table_with_empty_cells**: Verarbeitet Tabellen mit leeren Zellen in den Daten
- **test_table_with_spaces**: Verarbeitet Tabellen mit zusätzlichen Leerzeichen in Zellen
- **test_table_empty_string**: Löst ValueError für leere Eingabe-Strings aus
- **test_table_only_whitespace**: Löst ValueError für Strings aus, die nur Leerzeichen enthalten
- **test_table_insufficient_lines**: Löst ValueError aus, wenn die Eingabe weniger als 2 Zeilen hat
#### RenderTextabschnitteTest
- **test_render_empty_queryset**: Gibt leeren String für leere Querysets zurück
- **test_render_multiple_abschnitte**: Rendert mehrere Textabschnitte in korrekter Reihenfolge
- **test_render_text_markdown**: Konvertiert Klartext mit Markdown-Formatierung
- **test_render_ordered_list**: Rendert geordnete Listen korrekt
- **test_render_unordered_list**: Rendert ungeordnete Listen korrekt
- **test_render_code_block**: Rendert Code-Blöcke mit korrekter Syntax-Hervorhebung
- **test_render_table**: Konvertiert Markdown-Tabellen mit md_table_to_html nach HTML
- **test_render_diagram_success**: Testet die Diagramm-Generierung mit erfolgreichem Caching
- **test_render_diagram_error**: Behandelt Diagramm-Generierungsfehler angemessen
- **test_render_diagram_with_options**: Testet das Diagramm-Rendering mit benutzerdefinierten Optionen
- **test_render_text_with_footnotes**: Verarbeitet Text, der Fußnoten enthält
- **test_render_abschnitt_without_type**: Behandelt Textabschnitte ohne AbschnittTyp
- **test_render_abschnitt_with_empty_content**: Behandelt Textabschnitte mit leerem Inhalt
### Diagram-Caching-Tests
#### DiagramCacheTest
- **test_compute_hash**: Generiert konsistente SHA256-Hashes für dieselbe Eingabe
- **test_get_cache_path**: Erstellt korrekte Cache-Dateipfade basierend auf Hash und Typ
- **test_get_cached_diagram_hit**: Gibt zwischengespeichertes Diagramm zurück bei Cache-Treffer
- **test_get_cached_diagram_miss**: Generiert neues Diagramm bei Cache-Fehltreffer
- **test_get_cached_diagram_request_error**: Behandelt und löst Request-Fehler korrekt aus
- **test_clear_cache_specific_type**: Löscht Cache-Dateien für spezifische Diagrammtypen
- **test_clear_cache_all_types**: Löscht alle Cache-Dateien, wenn kein Typ angegeben ist
### Management-Befehl-Tests
#### ClearDiagramCacheCommandTest
- **test_command_without_type**: Testet die Ausführung des Management-Befehls ohne Angabe des Typs
- **test_command_with_type**: Testet die Ausführung des Management-Befehls mit spezifischem Diagrammtyp
### Integrations-Tests
#### IntegrationTest
- **test_textabschnitt_inheritance**: Überprüft, dass VorgabeLangtext Textabschnitt-Felder korrekt erbt
- **test_render_vorgabe_langtext**: Testet das Rendern von VorgabeLangtext durch render_textabschnitte
---
## dokumente App Tests
Die dokumente App enthält 98 Tests und ist damit die umfassendste Test-Suite, die alle Modelle, Views, URLs und Geschäftslogik abdeckt.
### Modell-Tests
#### DokumententypModelTest
- **test_dokumententyp_creation**: Überprüft die Erstellung von Dokumententyp mit korrekten Feldwerten
- **test_dokumententyp_str**: Testet die String-Repräsentation, die das `typ`-Feld zurückgibt
- **test_dokumententyp_verbose_name**: Validiert den korrekt gesetzten verbose_name
#### PersonModelTest
- **test_person_creation**: Testet die Erstellung von Person-Objekten mit Name und optionalem Titel
- **test_person_str**: Überprüft, dass die String-Repräsentation Titel und Namen enthält
- **test_person_verbose_name_plural**: Testet die Konfiguration von verbose_name_plural
#### ThemaModelTest
- **test_thema_creation**: Testet die Erstellung von Thema mit Name und optionaler Erklärung
- **test_thema_str**: Überprüft, dass die String-Repräsentation den Themennamen zurückgibt
- **test_thema_blank_erklaerung**: Bestätigt, dass das `erklaerung`-Feld leer sein kann
#### DokumentModelTest
- **test_dokument_creation**: Testet die Erstellung von Dokument mit erforderlichen und optionalen Feldern
- **test_dokument_str**: Überprüft, dass die String-Repräsentation den Dokumenttitel zurückgibt
- **test_dokument_optional_fields**: Testet, dass optionale Felder None oder leer sein können
- **test_dokument_many_to_many_relationships**: Überprüft Many-to-Many-Beziehungen mit Personen und Themen
#### VorgabeModelTest
- **test_vorgabe_creation**: Testet die Erstellung von Vorgabe mit allen erforderlichen Feldern
- **test_vorgabe_str**: Überprüft, dass die String-Repräsentation die Vorgabennummer zurückgibt
- **test_vorgabennummer**: Testet die automatische Generierung des Vorgabennummer-Formats
- **test_get_status_active**: Testet die Statusbestimmung für aktuelle aktive Vorgaben
- **test_get_status_expired**: Testet die Statusbestimmung für abgelaufene Vorgaben
- **test_get_status_future**: Testet die Statusbestimmung für zukünftige Vorgaben
- **test_get_status_with_custom_check_date**: Testet den Status mit benutzerdefiniertem Prüfdatum
- **test_get_status_verbose**: Testet die ausführliche Statusausgabe
#### ChangelogModelTest
- **test_changelog_creation**: Testet die Erstellung von Changelog mit Version, Datum und Beschreibung
- **test_changelog_str**: Überprüft, dass die String-Repräsentation Version und Datum enthält
#### ChecklistenfrageModelTest
- **test_checklistenfrage_creation**: Testet die Erstellung von Checklistenfrage mit Frage und optionaler Antwort
- **test_checklistenfrage_str**: Überprüft, dass die String-Repräsentation lange Fragen kürzt
- **test_checklistenfrage_related_name**: Testet die umgekehrte Beziehung von Vorgabe
### Text-Abschnitt-Tests
#### DokumentTextAbschnitteTest
- **test_einleitung_creation**: Testet die Erstellung von Einleitung und Vererbung von Textabschnitt
- **test_geltungsbereich_creation**: Testet die Erstellung von Geltungsbereich und Vererbung
#### VorgabeTextAbschnitteTest
- **test_vorgabe_kurztext_creation**: Testet die Erstellung von VorgabeKurztext und Vererbung
- **test_vorgabe_langtext_creation**: Testet die Erstellung von VorgabeLangtext und Vererbung
### Sanity-Check-Tests
#### VorgabeSanityCheckTest
- **test_date_ranges_intersect_no_overlap**: Testet Datumsüberschneidung mit nicht überlappenden Bereichen
- **test_date_ranges_intersect_with_overlap**: Testet Datumsüberschneidung mit überlappenden Bereichen
- **test_date_ranges_intersect_identical_ranges**: Testet Datumsüberschneidung mit identischen Bereichen
- **test_date_ranges_intersect_with_none_end_date**: Testet Überschneidung mit offenen Endbereichen
- **test_date_ranges_intersect_both_none_end_dates**: Testet Überschneidung mit zwei offenen Endbereichen
- **test_check_vorgabe_conflicts_utility**: Testet die Utility-Funktion zur Konflikterkennung
- **test_find_conflicts_no_conflicts**: Testet die Konflikterkennung bei Vorgabe ohne Konflikte
- **test_find_conflicts_with_conflicts**: Testet die Konflikterkennung mit konfliktbehafteten Vorgaben
- **test_format_conflict_report_no_conflicts**: Testet die Konfliktbericht-Formatierung ohne Konflikte
- **test_format_conflict_report_with_conflicts**: Testet die Konfliktbericht-Formatierung mit Konflikten
- **test_sanity_check_vorgaben_no_conflicts**: Testet vollständigen Sanity-Check ohne Konflikte
- **test_sanity_check_vorgaben_with_conflicts**: Testet vollständigen Sanity-Check mit Konflikten
- **test_sanity_check_vorgaben_multiple_conflicts**: Testet Sanity-Check mit mehreren Konfliktgruppen
- **test_vorgabe_clean_no_conflicts**: Testet Vorgabe.clean()-Methode ohne Konflikte
- **test_vorgabe_clean_with_conflicts**: Testet, dass Vorgabe.clean() ValidationError bei Konflikten auslöst
### Management-Befehl-Tests
#### SanityCheckManagementCommandTest
- **test_sanity_check_command_no_conflicts**: Testet Management-Befehlsausgabe ohne Konflikte
- **test_sanity_check_command_with_conflicts**: Testet Management-Befehlsausgabe mit Konflikten
### URL-Pattern-Tests
#### URLPatternsTest
- **test_standard_list_url_resolves**: Überprüft, dass standard_list URL zur korrekten View aufgelöst wird
- **test_standard_detail_url_resolves**: Überprüft, dass standard_detail URL mit pk-Parameter aufgelöst wird
- **test_standard_history_url_resolves**: Überprüft, dass standard_history URL mit check_date aufgelöst wird
- **test_standard_checkliste_url_resolves**: Überprüft, dass standard_checkliste URL mit pk aufgelöst wird
### View-Tests
#### ViewsTestCase
- **test_standard_list_view**: Testet, dass die Standard-Listen-View 200 zurückgibt und erwartete Inhalte enthält
- **test_standard_detail_view**: Testet die Standard-Detail-View mit existierendem Dokument
- **test_standard_detail_view_404**: Testet, dass die Standard-Detail-View 404 für nicht existierendes Dokument zurückgibt
- **test_standard_history_view**: Testet die Standard-Detail-View mit historischem check_date-Parameter
- **test_standard_checkliste_view**: Testet die Funktionalität der Checklisten-View
### JSON-Export-Tests
#### JSONExportManagementCommandTest
- **test_export_json_command_to_file**: Testet, dass der export_json-Befehl JSON in die angegebene Datei ausgibt
- **test_export_json_command_stdout**: Testet, dass der export_json-Befehl JSON an stdout ausgibt, wenn keine Datei angegeben ist
- **test_export_json_command_inactive_documents**: Testet, dass der export_json-Befehl inaktive Dokumente herausfiltert
- **test_export_json_command_empty_database**: Testet, dass der export_json-Befehl leere Datenbank angemessen behandelt
#### StandardJSONViewTest
- **test_standard_json_view_success**: Testet, dass die standard_json-View korrektes JSON für existierendes Dokument zurückgibt
- **test_standard_json_view_not_found**: Testet, dass die standard_json-View 404 für nicht existierendes Dokument zurückgibt
- **test_standard_json_view_json_formatting**: Testet, dass die standard_json-View korrekt formatiertes JSON zurückgibt
- **test_standard_json_view_null_dates**: Testet, dass die standard_json-View null-Datumfelder korrekt behandelt
- **test_standard_json_view_empty_sections**: Testet, dass die standard_json-View leere Dokumentabschnitte behandelt
### Unvollständige Vorgaben Tests
#### IncompleteVorgabenTest
- **test_incomplete_vorgaben_page_status**: Testet, dass die Seite erfolgreich lädt (200-Status)
- **test_incomplete_vorgaben_staff_only**: Testet, dass Nicht-Staff-Benutzer zum Login weitergeleitet werden
- **test_incomplete_vorgaben_page_content**: Testet, dass die Seite erwartete Überschriften und Struktur enthält
- **test_navigation_link**: Testet, dass die Navigation einen Link zur unvollständigen Vorgaben-Seite enthält
- **test_no_references_list**: Testet, dass Vorgaben ohne Referenzen korrekt aufgelistet werden
- **test_no_stichworte_list**: Testet, dass Vorgaben ohne Stichworte korrekt aufgelistet werden
- **test_no_text_list**: Testet, dass Vorgaben ohne Kurz- oder Langtext korrekt aufgelistet werden
- **test_no_checklistenfragen_list**: Testet, dass Vorgaben ohne Checklistenfragen korrekt aufgelistet werden
- **test_vorgabe_with_both_text_types**: Testet, dass Vorgabe mit beiden Texttypen als vollständig betrachtet wird
- **test_vorgabe_with_langtext_only**: Testet, dass Vorgabe mit nur Langtext immer noch unvollständig für Text ist
- **test_empty_lists_message**: Testet angemessene Nachrichten, wenn Listen leer sind
- **test_badge_counts**: Testet, dass Badge-Zähler korrekt berechnet werden
- **test_summary_section**: Testet, dass die Zusammenfassungssektion korrekte Zähler anzeigt
- **test_vorgabe_links**: Testet, dass Vorgaben zu korrekten Admin-Seiten verlinken
- **test_back_link**: Testet, dass der Zurück-Link zur Standardübersicht existiert
---
## pages App Tests
Die pages App enthält 4 Tests, die sich auf die Suchfunktionalität und Validierung konzentrieren.
### ViewsTestCase
- **test_search_view_get**: Testet GET-Anfrage an die Search-View gibt 200-Status zurück
- **test_search_view_post_with_query**: Testet POST-Anfrage mit Query gibt Ergebnisse zurück
- **test_search_view_post_empty_query**: Testet POST-Anfrage mit leerer Query zeigt Validierungsfehler
- **test_search_view_post_no_query**: Testet POST-Anfrage ohne Query-Parameter zeigt Validierungsfehler
---
## referenzen App Tests
Die referenzen App enthält 18 Tests, die sich auf MPTT-Hierarchiefunktionalität und Modellbeziehungen konzentrieren.
### Modell-Tests
#### ReferenzModelTest
- **test_referenz_creation**: Testet die Erstellung von Referenz mit erforderlichen Feldern
- **test_referenz_str**: Testet die String-Repräsentation gibt den Referenztext zurück
- **test_referenz_ordering**: Testet die Standard-Sortierung nach `order`-Feld
- **test_referenz_optional_fields**: Testet, dass optionale Felder leer sein können
#### ReferenzerklaerungModelTest
- **test_referenzerklaerung_creation**: Testet die Erstellung von Referenzerklaerung mit Referenz und Erklärung
- **test_referenzerklaerung_str**: Testet die String-Repräsentation enthält Referenz und Erklärungsvorschau
- **test_referenzerklaerung_ordering**: Testet die Standard-Sortierung nach `order`-Feld
- **test_referenzerklaerung_optional_explanation**: Testet, dass das Erklärungsfeld leer sein kann
### Hierarchie-Tests
#### ReferenzHierarchyTest
- **test_hierarchy_relationships**: Testet Eltern-Kind-Beziehungen im MPTT-Baum
- **test_get_root**: Testet das Abrufen des Wurzelknotens einer Hierarchie
- **test_get_children**: Testet das Abrufen direkter Kinder eines Knotens
- **test_get_descendants**: Testet das Abrufen aller Nachkommen eines Knotens
- **test_get_ancestors**: Testet das Abrufen aller Vorfahren eines Knotens
- **test_get_ancestors_include_self**: Testet das Abrufen von Vorfahren einschließlich des Knotens selbst
- **test_is_leaf_node**: Testet die Erkennung von Blattknoten
- **test_is_root_node**: Testet die Erkennung von Wurzelknoten
- **test_tree_ordering**: Testet die Baum-Sortierung mit mehreren Ebenen
- **test_move_node**: Testet das Verschieben von Knoten innerhalb der Baumstruktur
---
## rollen App Tests
Die rollen App enthält 18 Tests, die Rollenmodelle und ihre Beziehungen zu Dokumentabschnitten abdecken.
### Modell-Tests
#### RolleModelTest
- **test_rolle_creation**: Testet die Erstellung von Rolle mit Name und optionaler Beschreibung
- **test_rolle_str**: Testet die String-Repräsentation gibt den Rollennamen zurück
- **test_rolle_ordering**: Testet die Standard-Sortierung nach `order`-Feld
- **test_rolle_unique_name**: Testet, dass Rollennamen einzigartig sein müssen
- **test_rolle_optional_beschreibung**: Testet, dass das Beschreibungsfeld leer sein kann
#### RollenBeschreibungModelTest
- **test_rollenbeschreibung_creation**: Testet die Erstellung von RollenBeschreibung mit Rolle und Abschnittstyp
- **test_rollenbeschreibung_str**: Testet die String-Repräsentation enthält Rolle und Abschnittstyp
- **test_rollenbeschreibung_ordering**: Testet die Standard-Sortierung nach `order`-Feld
- **test_rollenbeschreibung_unique_combination**: Testet die Unique-Constraint auf Rolle und Abschnittstyp
- **test_rollenbeschreibung_optional_beschreibung**: Testet, dass das Beschreibungsfeld leer sein kann
### Beziehungs-Tests
#### RelationshipTest
- **test_rolle_rollenbeschreibung_relationship**: Testet die Eins-zu-viele-Beziehung zwischen Rolle und RollenBeschreibung
- **test_abschnitttyp_rollenbeschreibung_relationship**: Testet die Beziehung zwischen AbschnittTyp und RollenBeschreibung
- **test_cascade_delete**: Testet das Cascade-Delete-Verhalten beim Löschen einer Rolle
- **test_protected_delete**: Testet das Protected-Delete-Verhalten, wenn Abschnittstyp referenziert wird
- **test_query_related_objects**: Testet das effiziente Abfragen verwandter Objekte
- **test_string_representations**: Testet, dass alle String-Repräsentationen korrekt funktionieren
- **test_ordering_consistency**: Testet, dass die Sortierung über Abfragen hinweg konsistent ist
---
## stichworte App Tests
Die stichworte App enthält 18 Tests, die Schlüsselwortmodelle und ihre Sortierung abdecken.
### Modell-Tests
#### StichwortModelTest
- **test_stichwort_creation**: Testet die Erstellung von Stichwort mit Schlüsselworttext
- **test_stichwort_str**: Testet die String-Repräsentation gibt den Schlüsselworttext zurück
- **test_stichwort_ordering**: Testet die Standard-Sortierung nach `stichwort`-Feld
- **test_stichwort_unique**: Testet, dass Schlüsselwörter einzigartig sein müssen
- **test_stichwort_case_insensitive**: Testet die Groß-/Kleinschreibungs-unabhängige Eindeutigkeit
#### StichworterklaerungModelTest
- **test_stichworterklaerung_creation**: Testet die Erstellung von Stichworterklaerung mit Schlüsselwort und Erklärung
- **test_stichworterklaerung_str**: Testet die String-Repräsentation enthält Schlüsselwort und Erklärungsvorschau
- **test_stichworterklaerung_ordering**: Testet die Standard-Sortierung nach `order`-Feld
- **test_stichworterklaerung_optional_erklaerung**: Testet, dass das Erklärungsfeld leer sein kann
- **test_stichworterklaerung_unique_stichwort**: Testet den Unique-Constraint auf das Schlüsselwort
### Beziehungs-Tests
#### RelationshipTest
- **test_stichwort_stichworterklaerung_relationship**: Testet die Eins-zu-eins-Beziehung zwischen Stichwort und Stichworterklaerung
- **test_cascade_delete**: Testet das Cascade-Delete-Verhalten beim Löschen eines Schlüsselworts
- **test_protected_delete**: Testet das Protected-Delete-Verhalten, wenn Erklärung referenziert wird
- **test_query_related_objects**: Testet das effiziente Abfragen verwandter Objekte
- **test_string_representations**: Testet, dass alle String-Repräsentationen korrekt funktionieren
- **test_ordering_consistency**: Testet, dass die Sortierung über Abfragen hinweg konsistent ist
- **test_reverse_relationship**: Testet die umgekehrte Beziehung von Erklärung zu Schlüsselwort
---
## Test-Statistiken
- **Gesamt-Tests**: 206
- **abschnitte**: 32 Tests
- **dokumente**: 116 Tests (98 in tests.py + 9 in test_json.py + 9 JSON-Tests in Haupt-tests.py)
- **pages**: 4 Tests
- **referenzen**: 18 Tests
- **rollen**: 18 Tests
- **stichworte**: 18 Tests
## Test-Abdeckungsbereiche
1. **Modell-Validierung**: Feldvalidierung, Constraints und Beziehungen
2. **Geschäftslogik**: Statusbestimmung, Konflikterkennung, Hierarchieverwaltung
3. **View-Funktionalität**: HTTP-Antworten, Template-Rendering, URL-Auflösung
4. **Utility-Funktionen**: Textverarbeitung, Caching, Formatierung
5. **Management-Befehle**: CLI-Schnittstelle und Ausgabeverarbeitung
6. **Integration**: App-übergreifende Funktionalität und Datenfluss
## Ausführen der Tests
Um alle Tests auszuführen:
```bash
python manage.py test
```
Um Tests für eine spezifische App auszuführen:
```bash
python manage.py test app_name
```
Um mit ausführlicher Ausgabe auszuführen:
```bash
python manage.py test --verbosity=2
```
Alle Tests laufen derzeit erfolgreich und bieten umfassende Abdeckung der Funktionalität der Anwendung.

369
Test suite.md Normal file
View File

@@ -0,0 +1,369 @@
# Test Suite Documentation
This document provides a comprehensive overview of all tests in the vgui-cicd Django project, describing what each test does and how it works.
## Table of Contents
- [abschnitte App Tests](#abschnitte-app-tests)
- [dokumente App Tests](#dokumente-app-tests)
- [pages App Tests](#pages-app-tests)
- [referenzen App Tests](#referenzen-app-tests)
- [rollen App Tests](#rollen-app-tests)
- [stichworte App Tests](#stichworte-app-tests)
---
## abschnitte App Tests
The abschnitte app contains 32 tests covering models, utility functions, diagram caching, and management commands.
### Model Tests
#### AbschnittTypModelTest
- **test_abschnitttyp_creation**: Verifies that AbschnittTyp objects are created correctly with the expected field values
- **test_abschnitttyp_primary_key**: Confirms that the `abschnitttyp` field serves as the primary key
- **test_abschnitttyp_str**: Tests the string representation returns the `abschnitttyp` value
- **test_abschnitttyp_verbose_name_plural**: Validates the verbose name plural is set correctly
- **test_create_multiple_abschnitttypen**: Ensures multiple AbschnittTyp objects can be created with different types
#### TextabschnittModelTest
- **test_textabschnitt_creation**: Tests that Textabschnitt can be instantiated through the concrete model
- **test_textabschnitt_default_order**: Verifies the `order` field defaults to 0
- **test_textabschnitt_ordering**: Tests that Textabschnitt objects can be ordered by the `order` field
- **test_textabschnitt_blank_fields**: Confirms that `abschnitttyp` and `inhalt` fields can be blank/null
- **test_textabschnitt_foreign_key_protection**: Tests that AbschnittTyp objects are protected from deletion when referenced by Textabschnitt
### Utility Function Tests
#### MdTableToHtmlTest
- **test_simple_table**: Converts a basic markdown table with headers and one row to HTML
- **test_table_with_multiple_rows**: Tests conversion of tables with multiple data rows
- **test_table_with_empty_cells**: Handles tables with empty cells in the data
- **test_table_with_spaces**: Processes tables with extra spaces in cells
- **test_table_empty_string**: Raises ValueError for empty input strings
- **test_table_only_whitespace**: Raises ValueError for strings containing only whitespace
- **test_table_insufficient_lines**: Raises ValueError when input has fewer than 2 lines
#### RenderTextabschnitteTest
- **test_render_empty_queryset**: Returns empty string for empty querysets
- **test_render_multiple_abschnitte**: Renders multiple Textabschnitte in correct order
- **test_render_text_markdown**: Converts plain text with markdown formatting
- **test_render_ordered_list**: Renders ordered lists correctly
- **test_render_unordered_list**: Renders unordered lists correctly
- **test_render_code_block**: Renders code blocks with proper syntax highlighting
- **test_render_table**: Converts markdown tables to HTML using md_table_to_html
- **test_render_diagram_success**: Tests diagram generation with successful caching
- **test_render_diagram_error**: Handles diagram generation errors gracefully
- **test_render_diagram_with_options**: Tests diagram rendering with custom options
- **test_render_text_with_footnotes**: Processes text containing footnotes
- **test_render_abschnitt_without_type**: Handles Textabschnitte without AbschnittTyp
- **test_render_abschnitt_with_empty_content**: Handles Textabschnitte with empty content
### Diagram Caching Tests
#### DiagramCacheTest
- **test_compute_hash**: Generates consistent SHA256 hashes for the same input
- **test_get_cache_path**: Creates correct cache file paths based on hash and type
- **test_get_cached_diagram_hit**: Returns cached diagram when cache hit occurs
- **test_get_cached_diagram_miss**: Generates new diagram when cache miss occurs
- **test_get_cached_diagram_request_error**: Properly handles and raises request errors
- **test_clear_cache_specific_type**: Clears cache files for specific diagram types
- **test_clear_cache_all_types**: Clears all cache files when no type specified
### Management Command Tests
#### ClearDiagramCacheCommandTest
- **test_command_without_type**: Tests management command execution without specifying type
- **test_command_with_type**: Tests management command execution with specific diagram type
### Integration Tests
#### IntegrationTest
- **test_textabschnitt_inheritance**: Verifies VorgabeLangtext properly inherits Textabschnitt fields
- **test_render_vorgabe_langtext**: Tests rendering VorgabeLangtext through render_textabschnitte
---
## dokumente App Tests
The dokumente app contains 98 tests, making it the most comprehensive test suite, covering all models, views, URLs, and business logic.
### Model Tests
#### DokumententypModelTest
- **test_dokumententyp_creation**: Verifies Dokumententyp creation with correct field values
- **test_dokumententyp_str**: Tests string representation returns the `typ` field
- **test_dokumententyp_verbose_name**: Validates verbose name is set correctly
#### PersonModelTest
- **test_person_creation**: Tests Person object creation with name and optional title
- **test_person_str**: Verifies string representation includes title and name
- **test_person_verbose_name_plural**: Tests verbose name plural configuration
#### ThemaModelTest
- **test_thema_creation**: Tests Thema creation with name and optional explanation
- **test_thema_str**: Verifies string representation returns the theme name
- **test_thema_blank_erklaerung**: Confirms `erklaerung` field can be blank
#### DokumentModelTest
- **test_dokument_creation**: Tests Dokument creation with required and optional fields
- **test_dokument_str**: Verifies string representation returns the document title
- **test_dokument_optional_fields**: Tests that optional fields can be None or blank
- **test_dokument_many_to_many_relationships**: Verifies many-to-many relationships with Personen and Themen
#### VorgabeModelTest
- **test_vorgabe_creation**: Tests Vorgabe creation with all required fields
- **test_vorgabe_str**: Verifies string representation returns the Vorgabennummer
- **test_vorgabennummer**: Tests automatic generation of Vorgabennummer format
- **test_get_status_active**: Tests status determination for current active Vorgaben
- **test_get_status_expired**: Tests status determination for expired Vorgaben
- **test_get_status_future**: Tests status determination for future Vorgaben
- **test_get_status_with_custom_check_date**: Tests status with custom check date
- **test_get_status_verbose**: Tests verbose status output
#### ChangelogModelTest
- **test_changelog_creation**: Tests Changelog creation with version, date, and description
- **test_changelog_str**: Verifies string representation includes version and date
#### ChecklistenfrageModelTest
- **test_checklistenfrage_creation**: Tests Checklistenfrage creation with question and optional answer
- **test_checklistenfrage_str**: Verifies string representation truncates long questions
- **test_checklistenfrage_related_name**: Tests the reverse relationship from Vorgabe
### Text Abschnitt Tests
#### DokumentTextAbschnitteTest
- **test_einleitung_creation**: Tests Einleitung creation and inheritance from Textabschnitt
- **test_geltungsbereich_creation**: Tests Geltungsbereich creation and inheritance
#### VorgabeTextAbschnitteTest
- **test_vorgabe_kurztext_creation**: Tests VorgabeKurztext creation and inheritance
- **test_vorgabe_langtext_creation**: Tests VorgabeLangtext creation and inheritance
### Sanity Check Tests
#### VorgabeSanityCheckTest
- **test_date_ranges_intersect_no_overlap**: Tests date intersection with non-overlapping ranges
- **test_date_ranges_intersect_with_overlap**: Tests date intersection with overlapping ranges
- **test_date_ranges_intersect_identical_ranges**: Tests date intersection with identical ranges
- **test_date_ranges_intersect_with_none_end_date**: Tests intersection with open-ended ranges
- **test_date_ranges_intersect_both_none_end_dates**: Tests intersection with two open-ended ranges
- **test_check_vorgabe_conflicts_utility**: Tests the utility function for conflict detection
- **test_find_conflicts_no_conflicts**: Tests conflict detection on Vorgabe without conflicts
- **test_find_conflicts_with_conflicts**: Tests conflict detection with conflicting Vorgaben
- **test_format_conflict_report_no_conflicts**: Tests conflict report formatting with no conflicts
- **test_format_conflict_report_with_conflicts**: Tests conflict report formatting with conflicts
- **test_sanity_check_vorgaben_no_conflicts**: Tests full sanity check with no conflicts
- **test_sanity_check_vorgaben_with_conflicts**: Tests full sanity check with conflicts
- **test_sanity_check_vorgaben_multiple_conflicts**: Tests sanity check with multiple conflict groups
- **test_vorgabe_clean_no_conflicts**: Tests Vorgabe.clean() method without conflicts
- **test_vorgabe_clean_with_conflicts**: Tests Vorgabe.clean() raises ValidationError with conflicts
### Management Command Tests
#### SanityCheckManagementCommandTest
- **test_sanity_check_command_no_conflicts**: Tests management command output with no conflicts
- **test_sanity_check_command_with_conflicts**: Tests management command output with conflicts
### URL Pattern Tests
#### URLPatternsTest
- **test_standard_list_url_resolves**: Verifies standard_list URL resolves to correct view
- **test_standard_detail_url_resolves**: Verifies standard_detail URL resolves with pk parameter
- **test_standard_history_url_resolves**: Verifies standard_history URL resolves with check_date
- **test_standard_checkliste_url_resolves**: Verifies standard_checkliste URL resolves with pk
### View Tests
#### ViewsTestCase
- **test_standard_list_view**: Tests standard list view returns 200 and contains expected content
- **test_standard_detail_view**: Tests standard detail view with existing document
- **test_standard_detail_view_404**: Tests standard detail view returns 404 for non-existent document
- **test_standard_history_view**: Tests standard detail view with historical check_date parameter
- **test_standard_checkliste_view**: Tests checklist view functionality
### JSON Export Tests
#### JSONExportManagementCommandTest
- **test_export_json_command_to_file**: Tests export_json command outputs JSON to specified file
- **test_export_json_command_stdout**: Tests export_json command outputs JSON to stdout when no file specified
- **test_export_json_command_inactive_documents**: Tests export_json command filters out inactive documents
- **test_export_json_command_empty_database**: Tests export_json command handles empty database gracefully
#### StandardJSONViewTest
- **test_standard_json_view_success**: Tests standard_json view returns correct JSON for existing document
- **test_standard_json_view_not_found**: Tests standard_json view returns 404 for non-existent document
- **test_standard_json_view_json_formatting**: Tests standard_json view returns properly formatted JSON
- **test_standard_json_view_null_dates**: Tests standard_json view handles null date fields correctly
- **test_standard_json_view_empty_sections**: Tests standard_json view handles empty document sections
### Incomplete Vorgaben Tests
#### IncompleteVorgabenTest
- **test_incomplete_vorgaben_page_status**: Tests page loads successfully (200 status)
- **test_incomplete_vorgaben_staff_only**: Tests non-staff users are redirected to login
- **test_incomplete_vorgaben_page_content**: Tests page contains expected headings and structure
- **test_navigation_link**: Tests navigation includes link to incomplete Vorgaben page
- **test_no_references_list**: Tests Vorgaben without references are listed correctly
- **test_no_stichworte_list**: Tests Vorgaben without Stichworte are listed correctly
- **test_no_text_list**: Tests Vorgaben without Kurz- or Langtext are listed correctly
- **test_no_checklistenfragen_list**: Tests Vorgaben without Checklistenfragen are listed correctly
- **test_vorgabe_with_both_text_types**: Tests Vorgabe with both text types is considered complete
- **test_vorgabe_with_langtext_only**: Tests Vorgabe with only Langtext is still incomplete for text
- **test_empty_lists_message**: Tests appropriate messages when lists are empty
- **test_badge_counts**: Tests badge counts are calculated correctly
- **test_summary_section**: Tests summary section shows correct counts
- **test_vorgabe_links**: Tests Vorgaben link to correct admin pages
- **test_back_link**: Tests back link to standard list exists
---
## pages App Tests
The pages app contains 4 tests focusing on search functionality and validation.
### ViewsTestCase
- **test_search_view_get**: Tests GET request to search view returns 200 status
- **test_search_view_post_with_query**: Tests POST request with query returns results
- **test_search_view_post_empty_query**: Tests POST request with empty query shows validation error
- **test_search_view_post_no_query**: Tests POST request without query parameter shows validation error
---
## referenzen App Tests
The referenzen app contains 18 tests focusing on MPTT hierarchy functionality and model relationships.
### Model Tests
#### ReferenzModelTest
- **test_referenz_creation**: Tests Referenz creation with required fields
- **test_referenz_str**: Tests string representation returns the reference text
- **test_referenz_ordering**: Tests default ordering by `order` field
- **test_referenz_optional_fields**: Tests optional fields can be blank
#### ReferenzerklaerungModelTest
- **test_referenzerklaerung_creation**: Tests Referenzerklaerung creation with reference and explanation
- **test_referenzerklaerung_str**: Tests string representation includes reference and explanation preview
- **test_referenzerklaerung_ordering**: Tests default ordering by `order` field
- **test_referenzerklaerung_optional_explanation**: Tests explanation field can be blank
### Hierarchy Tests
#### ReferenzHierarchyTest
- **test_hierarchy_relationships**: Tests parent-child relationships in MPTT tree
- **test_get_root**: Tests getting the root node of a hierarchy
- **test_get_children**: Tests getting direct children of a node
- **test_get_descendants**: Tests getting all descendants of a node
- **test_get_ancestors**: Tests getting all ancestors of a node
- **test_get_ancestors_include_self**: Tests getting ancestors including the node itself
- **test_is_leaf_node**: Tests leaf node detection
- **test_is_root_node**: Tests root node detection
- **test_tree_ordering**: Tests tree ordering with multiple levels
- **test_move_node**: Tests moving nodes within the tree structure
---
## rollen App Tests
The rollen app contains 18 tests covering role models and their relationships with document sections.
### Model Tests
#### RolleModelTest
- **test_rolle_creation**: Tests Rolle creation with name and optional description
- **test_rolle_str**: Tests string representation returns the role name
- **test_rolle_ordering**: Tests default ordering by `order` field
- **test_rolle_unique_name**: Tests that role names must be unique
- **test_rolle_optional_beschreibung**: Tests description field can be blank
#### RollenBeschreibungModelTest
- **test_rollenbeschreibung_creation**: Tests RollenBeschreibung creation with role and section type
- **test_rollenbeschreibung_str**: Tests string representation includes role and section type
- **test_rollenbeschreibung_ordering**: Tests default ordering by `order` field
- **test_rollenbeschreibung_unique_combination**: Tests unique constraint on role and section type
- **test_rollenbeschreibung_optional_beschreibung**: Tests description field can be blank
### Relationship Tests
#### RelationshipTest
- **test_rolle_rollenbeschreibung_relationship**: Tests one-to-many relationship between Rolle and RollenBeschreibung
- **test_abschnitttyp_rollenbeschreibung_relationship**: Tests relationship between AbschnittTyp and RollenBeschreibung
- **test_cascade_delete**: Tests cascade delete behavior when role is deleted
- **test_protected_delete**: Tests protected delete behavior when section type is referenced
- **test_query_related_objects**: Tests querying related objects efficiently
- **test_string_representations**: Tests all string representations work correctly
- **test_ordering_consistency**: Tests ordering is consistent across queries
---
## stichworte App Tests
The stichworte app contains 18 tests covering keyword models and their ordering.
### Model Tests
#### StichwortModelTest
- **test_stichwort_creation**: Tests Stichwort creation with keyword text
- **test_stichwort_str**: Tests string representation returns the keyword text
- **test_stichwort_ordering**: Tests default ordering by `stichwort` field
- **test_stichwort_unique**: Tests that keywords must be unique
- **test_stichwort_case_insensitive**: Tests case-insensitive uniqueness
#### StichworterklaerungModelTest
- **test_stichworterklaerung_creation**: Tests Stichworterklaerung creation with keyword and explanation
- **test_stichworterklaerung_str**: Tests string representation includes keyword and explanation preview
- **test_stichworterklaerung_ordering**: Tests default ordering by `order` field
- **test_stichworterklaerung_optional_erklaerung**: Tests explanation field can be blank
- **test_stichworterklaerung_unique_stichwort**: Tests unique constraint on keyword
### Relationship Tests
#### RelationshipTest
- **test_stichwort_stichworterklaerung_relationship**: Tests one-to-one relationship between Stichwort and Stichworterklaerung
- **test_cascade_delete**: Tests cascade delete behavior when keyword is deleted
- **test_protected_delete**: Tests protected delete behavior when explanation is referenced
- **test_query_related_objects**: Tests querying related objects efficiently
- **test_string_representations**: Tests all string representations work correctly
- **test_ordering_consistency**: Tests ordering is consistent across queries
- **test_reverse_relationship**: Tests reverse relationship from explanation to keyword
---
## Test Statistics
- **Total Tests**: 206
- **abschnitte**: 32 tests
- **dokumente**: 116 tests (98 in tests.py + 9 in test_json.py + 9 JSON tests in main tests.py)
- **pages**: 4 tests
- **referenzen**: 18 tests
- **rollen**: 18 tests
- **stichworte**: 18 tests
## Test Coverage Areas
1. **Model Validation**: Field validation, constraints, and relationships
2. **Business Logic**: Status determination, conflict detection, hierarchy management
3. **View Functionality**: HTTP responses, template rendering, URL resolution
4. **Utility Functions**: Text processing, caching, formatting
5. **Management Commands**: CLI interface and output handling
6. **Integration**: Cross-app functionality and data flow
## Running the Tests
To run all tests:
```bash
python manage.py test
```
To run tests for a specific app:
```bash
python manage.py test app_name
```
To run with verbose output:
```bash
python manage.py test --verbosity=2
```
All tests are currently passing and provide comprehensive coverage of the application's functionality.

View File

@@ -51,7 +51,6 @@ INSTALLED_APPS = [
'mptt',
'pages',
'nested_admin',
'revproxy.apps.RevProxyConfig',
]
MIDDLEWARE = [

View File

@@ -18,7 +18,6 @@ from django.contrib import admin
from django.urls import include, path, re_path
from django.conf import settings
from django.conf.urls.static import static
from diagramm_proxy.views import DiagrammProxyView
import dokumente.views
import pages.views
import referenzen.views
@@ -33,7 +32,6 @@ urlpatterns = [
path('stichworte/', include("stichworte.urls")),
path('referenzen/', referenzen.views.tree, name="referenz_tree"),
path('referenzen/<str:refid>/', referenzen.views.detail, name="referenz_detail"),
re_path(r'^diagramm/(?P<path>.*)$', DiagrammProxyView.as_view()),
]
# Serve static files

View File

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

View File

@@ -0,0 +1,102 @@
/* Better visual separation for Vorgaben inlines */
.inline-group[data-inline-model="vorgabe"] {
border: 2px solid #ddd;
border-radius: 8px;
margin-bottom: 20px;
padding: 15px;
background-color: #f9f9f9;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.inline-group[data-inline-model="vorgabe"] .inline-related {
border: 1px solid #ccc;
border-radius: 6px;
margin-bottom: 10px;
background-color: white;
padding: 10px;
}
.inline-group[data-inline-model="vorgabe"] h3 {
background-color: #007cba;
color: white;
padding: 8px 12px;
margin: -15px -15px 10px -15px;
border-radius: 6px 6px 0 0;
font-weight: bold;
}
.inline-group[data-inline-model="vorgabe"] .collapse .inline-related {
border-left: 3px solid #007cba;
}
/* Better spacing for nested inlines */
.inline-group[data-inline-model="vorgabe"] .inline-group {
margin-top: 10px;
}
.inline-group[data-inline-model="vorgabe"] .inline-group h3 {
background-color: #f0f8ff;
color: #333;
padding: 6px 10px;
margin: 0 0 8px 0;
border-left: 3px solid #007cba;
}
/* Highlight active/expanded vorgabe */
.inline-group[data-inline-model="vorgabe"] .inline-related:not(.collapsed) {
border-color: #007cba;
box-shadow: 0 0 8px rgba(0,124,186,0.2);
}
/* Highlight actively edited vorgabe */
.inline-group[data-inline-model="vorgabe"] .inline-related.active-edit {
border-color: #28a745;
box-shadow: 0 0 12px rgba(40,167,69,0.3);
background-color: #f8fff9;
}
/* Toggle hint styling */
.toggle-hint {
font-size: 0.8em;
color: #666;
font-weight: normal;
}
/* Better fieldset styling for vorgabe inlines */
.inline-group[data-inline-model="vorgabe"] .fieldset {
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
background-color: #fafafa;
}
.inline-group[data-inline-model="vorgabe"] .fieldset h2 {
background-color: #e3f2fd;
color: #1565c0;
padding: 5px 10px;
margin: -10px -10px 10px -10px;
border-radius: 4px 4px 0 0;
font-size: 0.9em;
font-weight: bold;
}
/* Better form layout */
.inline-group[data-inline-model="vorgabe"] .form-row {
border-bottom: 1px solid #eee;
padding: 8px 0;
}
.inline-group[data-inline-model="vorgabe"] .form-row:last-child {
border-bottom: none;
}
/* Wide fields styling */
.inline-group[data-inline-model="vorgabe"] .wide .form-row > div {
width: 100%;
}
.inline-group[data-inline-model="vorgabe"] .wide textarea {
width: 100%;
min-height: 80px;
}

View File

@@ -0,0 +1,25 @@
(function($) {
'use strict';
$(document).ready(function() {
// Add toggle buttons for each vorgabe inline
$('.inline-group[data-inline-model="vorgabe"]').each(function() {
var $group = $(this);
var $headers = $group.find('h3');
$headers.css('cursor', 'pointer').append(' <span class="toggle-hint">(klicken zum umschalten)</span>');
$headers.on('click', function(e) {
e.preventDefault();
var $inline = $(this).closest('.inline-related');
$inline.find('.collapse').toggleClass('collapsed');
});
});
// Highlight active vorgabe when editing
$('.inline-group[data-inline-model="vorgabe"] .inline-related').on('click', function() {
$('.inline-group[data-inline-model="vorgabe"] .inline-related').removeClass('active-edit');
$(this).addClass('active-edit');
});
});
})(django.jQuery);

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -1,4 +0,0 @@
from revproxy.views import ProxyView
class DiagrammProxyView(ProxyView):
upstream = "http://svckroki:8000/"

View File

@@ -2,8 +2,10 @@ from django.contrib import admin
#from nested_inline.admin import NestedStackedInline, NestedModelAdmin
from nested_admin import NestedStackedInline, NestedModelAdmin, NestedTabularInline
from django import forms
from django.utils.html import format_html
from mptt.forms import TreeNodeMultipleChoiceField
from mptt.admin import DraggableMPTTAdmin
from adminsortable2.admin import SortableInlineAdminMixin, SortableAdminBase
# Register your models here.
from .models import *
@@ -20,21 +22,33 @@ from referenzen.models import Referenz
# 'frage': forms.Textarea(attrs={'rows': 1, 'cols': 100}),
# }
class ChecklistenfragenInline(NestedTabularInline):
class ChecklistenfragenInline(NestedStackedInline):
model=Checklistenfrage
extra=0
fk_name="vorgabe"
# form=ChecklistenForm
classes = ['collapse']
verbose_name_plural = "Checklistenfragen"
fieldsets = (
(None, {
'fields': ('frage',),
'classes': ('wide',),
}),
)
class VorgabeKurztextInline(NestedTabularInline):
class VorgabeKurztextInline(NestedStackedInline):
model=VorgabeKurztext
extra=0
sortable_field_name = "order"
show_change_link=True
classes = ['collapse']
#inline=inhalt
verbose_name_plural = "Kurztext-Abschnitte"
fieldsets = (
(None, {
'fields': ('abschnitttyp', 'inhalt', 'order'),
'classes': ('wide',),
}),
)
class VorgabeLangtextInline(NestedStackedInline):
model=VorgabeLangtext
@@ -42,42 +56,75 @@ class VorgabeLangtextInline(NestedStackedInline):
sortable_field_name = "order"
show_change_link=True
classes = ['collapse']
#inline=inhalt
verbose_name_plural = "Langtext-Abschnitte"
fieldsets = (
(None, {
'fields': ('abschnitttyp', 'inhalt', 'order'),
'classes': ('wide',),
}),
)
class GeltungsbereichInline(NestedTabularInline):
class GeltungsbereichInline(NestedStackedInline):
model=Geltungsbereich
extra=0
sortable_field_name = "order"
show_change_link=True
classes = ['collapse']
classes = ['collapse']
#inline=inhalt
verbose_name_plural = "Geltungsbereich-Abschnitte"
fieldsets = (
(None, {
'fields': ('abschnitttyp', 'inhalt', 'order'),
'classes': ('wide',),
}),
)
class EinleitungInline(NestedTabularInline):
model = Einleitung
extra = 0
sortable_field_name = "order"
show_change_link = True
classes = ['collapse']
class EinleitungInline(NestedStackedInline):
model = Einleitung
extra = 0
sortable_field_name = "order"
show_change_link = True
classes = ['collapse']
verbose_name_plural = "Einleitungs-Abschnitte"
fieldsets = (
(None, {
'fields': ('abschnitttyp', 'inhalt', 'order'),
'classes': ('wide',),
}),
)
class VorgabeForm(forms.ModelForm):
# referenzen = TreeNodeMultipleChoiceField(queryset=Referenz.objects.all(), required=False)
referenzen = TreeNodeMultipleChoiceField(queryset=Referenz.objects.all(), required=False)
class Meta:
model = Vorgabe
fields = '__all__'
class VorgabeInline(NestedTabularInline): # or StackedInline for more vertical layout
class VorgabeInline(SortableInlineAdminMixin, NestedStackedInline):
model = Vorgabe
form = VorgabeForm
extra = 0
#show_change_link = True
inlines = [VorgabeKurztextInline,VorgabeLangtextInline,ChecklistenfragenInline]
sortable_field_name = "order"
show_change_link = False
can_delete = False
inlines = [VorgabeKurztextInline, VorgabeLangtextInline, ChecklistenfragenInline]
autocomplete_fields = ['stichworte','referenzen','relevanz']
#search_fields=['nummer','name']ModelAdmin.
list_filter=['stichworte']
#classes=["collapse"]
# Remove collapse class so Vorgaben show by default
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
extra=0
sortable_field_name = "order"
@@ -86,9 +133,57 @@ class StichworterklaerungInline(NestedStackedInline):
@admin.register(Stichwort)
class StichwortAdmin(NestedModelAdmin):
list_display = ('stichwort', 'vorgaben_count')
search_fields = ('stichwort',)
ordering=('stichwort',)
inlines=[StichworterklaerungInline]
readonly_fields = ('vorgaben_list',)
fieldsets = (
(None, {
'fields': ('stichwort', 'vorgaben_list')
}),
)
def vorgaben_count(self, obj):
"""Count the number of Vorgaben that have this Stichwort"""
count = obj.vorgabe_set.count()
return f"{count} Vorgabe{'n' if count != 1 else ''}"
vorgaben_count.short_description = "Anzahl Vorgaben"
def vorgaben_list(self, obj):
"""Display list of Vorgaben that use this Stichwort"""
vorgaben = obj.vorgabe_set.select_related('dokument', 'thema').order_by('dokument__nummer', 'nummer')
vorgaben_list = list(vorgaben) # Evaluate queryset once
count = len(vorgaben_list)
if count == 0:
return format_html("<em>Keine Vorgaben gefunden</em><p><strong>Gesamt: 0 Vorgaben</strong></p>")
html = "<div style='max-height: 300px; overflow-y: auto;'>"
html += "<table style='width: 100%; border-collapse: collapse;'>"
html += "<thead><tr style='background-color: #f5f5f5;'>"
html += "<th style='padding: 8px; border: 1px solid #ddd; text-align: left;'>Vorgabe</th>"
html += "<th style='padding: 8px; border: 1px solid #ddd; text-align: left;'>Titel</th>"
html += "<th style='padding: 8px; border: 1px solid #ddd; text-align: left;'>Dokument</th>"
html += "</tr></thead>"
html += "<tbody>"
for vorgabe in vorgaben_list:
html += "<tr>"
html += f"<td style='padding: 6px; border: 1px solid #ddd;'>{vorgabe.Vorgabennummer()}</td>"
html += f"<td style='padding: 6px; border: 1px solid #ddd;'>{vorgabe.titel}</td>"
html += f"<td style='padding: 6px; border: 1px solid #ddd;'>{vorgabe.dokument.nummer} {vorgabe.dokument.name}</td>"
html += "</tr>"
html += "</tbody></table>"
html += f"</div><p><strong>Gesamt: {count} Vorgabe{'n' if count != 1 else ''}</strong></p>"
return format_html(html)
vorgaben_list.short_description = "Zugeordnete Vorgaben"
def get_queryset(self, request):
"""Optimize queryset with related data"""
return super().get_queryset(request).prefetch_related('vorgabe_set')
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
@@ -100,28 +195,104 @@ class PersonAdmin(admin.ModelAdmin):
@admin.register(Dokument)
class DokumentAdmin(NestedModelAdmin):
class DokumentAdmin(SortableAdminBase, NestedModelAdmin):
actions_on_top=True
inlines = [EinleitungInline,GeltungsbereichInline,VorgabeInline]
#filter_horizontal=['autoren','pruefende']
list_display=['nummer','name','dokumententyp']
inlines = [EinleitungInline, GeltungsbereichInline, VorgabeInline]
filter_horizontal=['autoren','pruefende']
list_display=['nummer','name','dokumententyp','gueltigkeit_von','gueltigkeit_bis','aktiv']
search_fields=['nummer','name']
list_filter=['dokumententyp','aktiv','gueltigkeit_von']
fieldsets = (
('Grunddaten', {
'fields': ('nummer', 'name', 'dokumententyp', 'aktiv'),
'classes': ('wide',),
}),
('Verantwortlichkeiten', {
'fields': ('autoren', 'pruefende'),
'classes': ('wide', 'collapse'),
}),
('Gültigkeit & Metadaten', {
'fields': ('gueltigkeit_von', 'gueltigkeit_bis', 'signatur_cso', 'anhaenge'),
'classes': ('wide', 'collapse'),
}),
)
class Media:
# js = ('admin/js/vorgabe_collapse.js',)
js = ('admin/js/vorgabe_collapse.js',)
css = {
'all': ('admin/css/vorgabe_border.css',
# 'admin/css/vorgabe_collapse.css',
)
'all': ('admin/css/vorgabe_border.css',)
}
#admin.site.register(Stichwort)
@admin.register(VorgabenTable)
class VorgabenTableAdmin(admin.ModelAdmin):
list_display = ['order', 'nummer', 'dokument', 'thema', 'titel', 'gueltigkeit_von', 'gueltigkeit_bis']
list_display_links = ['dokument']
list_editable = ['order', 'nummer', 'thema', 'titel', 'gueltigkeit_von', 'gueltigkeit_bis']
list_filter = ['dokument', 'thema', 'gueltigkeit_von', 'gueltigkeit_bis']
search_fields = ['nummer', 'titel', 'dokument__nummer', 'dokument__name']
autocomplete_fields = ['dokument', 'thema', 'stichworte', 'referenzen', 'relevanz']
ordering = ['order']
list_per_page = 100
fieldsets = (
('Grunddaten', {
'fields': ('order', 'nummer', 'dokument', 'thema', 'titel')
}),
('Gültigkeit', {
'fields': ('gueltigkeit_von', 'gueltigkeit_bis')
}),
('Verknüpfungen', {
'fields': ('referenzen', 'stichworte', 'relevanz'),
'classes': ('collapse',)
}),
)
@admin.register(Thema)
class ThemaAdmin(admin.ModelAdmin):
search_fields = ['name']
ordering = ['name']
@admin.register(Vorgabe)
class VorgabeAdmin(NestedModelAdmin):
form = VorgabeForm
list_display = ['vorgabe_nummer', 'titel', 'dokument', 'thema', 'gueltigkeit_von', 'gueltigkeit_bis']
list_filter = ['dokument', 'thema', 'gueltigkeit_von', 'gueltigkeit_bis']
search_fields = ['nummer', 'titel', 'dokument__nummer', 'dokument__name']
autocomplete_fields = ['stichworte', 'referenzen', 'relevanz']
ordering = ['dokument', 'order']
inlines = [
VorgabeKurztextInline,
VorgabeLangtextInline,
ChecklistenfragenInline
]
fieldsets = (
('Grunddaten', {
'fields': (('order', 'nummer'), ('dokument', 'thema'), 'titel'),
'classes': ('wide',),
}),
('Gültigkeit', {
'fields': (('gueltigkeit_von', 'gueltigkeit_bis'),),
'classes': ('wide',),
}),
('Verknüpfungen', {
'fields': (('referenzen', 'stichworte', 'relevanz'),),
'classes': ('wide',),
}),
)
def vorgabe_nummer(self, obj):
return obj.Vorgabennummer()
vorgabe_nummer.short_description = 'Vorgabennummer'
admin.site.register(Checklistenfrage)
admin.site.register(Dokumententyp)
#admin.site.register(Person)
admin.site.register(Thema)
#admin.site.register(Referenz, DraggableM§PTTAdmin)
admin.site.register(Vorgabe)
#admin.site.register(Changelog)

View File

@@ -0,0 +1,174 @@
from django.core.management.base import BaseCommand
from django.core.serializers.json import DjangoJSONEncoder
import json
from datetime import datetime
from dokumente.models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage
class Command(BaseCommand):
help = 'Export all dokumente as JSON using R0066.json format as reference'
def add_arguments(self, parser):
parser.add_argument(
'--output',
type=str,
help='Output file path (default: stdout)',
)
def handle(self, *args, **options):
# Get all active documents
dokumente = Dokument.objects.filter(aktiv=True).prefetch_related(
'autoren', 'pruefende', 'vorgaben__thema',
'vorgaben__referenzen', 'vorgaben__stichworte',
'vorgaben__checklistenfragen', 'vorgaben__vorgabekurztext_set',
'vorgaben__vorgabelangtext_set', 'geltungsbereich_set',
'einleitung_set', 'changelog__autoren'
).order_by('nummer')
result = {
"Vorgabendokument": {
"Typ": "Standard IT-Sicherheit",
"Nummer": "", # Will be set per document
"Name": "", # Will be set per document
"Autoren": [], # Will be set per document
"Pruefende": [], # Will be set per document
"Geltungsbereich": {
"Abschnitt": []
},
"Ziel": "",
"Grundlagen": "",
"Changelog": [],
"Anhänge": [],
"Verantwortlich": "Information Security Management BIT",
"Klassifizierung": None,
"Glossar": {},
"Vorgaben": []
}
}
output_data = []
for dokument in dokumente:
# Build document structure
doc_data = {
"Typ": dokument.dokumententyp.name if dokument.dokumententyp else "",
"Nummer": dokument.nummer,
"Name": dokument.name,
"Autoren": [autor.name for autor in dokument.autoren.all()],
"Pruefende": [pruefender.name for pruefender in dokument.pruefende.all()],
"Gueltigkeit": {
"Von": dokument.gueltigkeit_von.strftime("%Y-%m-%d") if dokument.gueltigkeit_von else "",
"Bis": dokument.gueltigkeit_bis.strftime("%Y-%m-%d") if dokument.gueltigkeit_bis else None
},
"SignaturCSO": dokument.signatur_cso,
"Geltungsbereich": {},
"Einleitung": {},
"Ziel": "",
"Grundlagen": "",
"Changelog": [],
"Anhänge": dokument.anhaenge,
"Verantwortlich": "Information Security Management BIT",
"Klassifizierung": None,
"Glossar": {},
"Vorgaben": []
}
# Process Geltungsbereich sections
geltungsbereich_sections = []
for gb in dokument.geltungsbereich_set.all().order_by('order'):
geltungsbereich_sections.append({
"typ": gb.abschnitttyp.abschnitttyp if gb.abschnitttyp else "text",
"inhalt": gb.inhalt
})
if geltungsbereich_sections:
doc_data["Geltungsbereich"] = {
"Abschnitt": geltungsbereich_sections
}
# Process Einleitung sections
einleitung_sections = []
for ei in dokument.einleitung_set.all().order_by('order'):
einleitung_sections.append({
"typ": ei.abschnitttyp.abschnitttyp if ei.abschnitttyp else "text",
"inhalt": ei.inhalt
})
if einleitung_sections:
doc_data["Einleitung"] = {
"Abschnitt": einleitung_sections
}
# Process Changelog entries
changelog_entries = []
for cl in dokument.changelog.all().order_by('-datum'):
changelog_entries.append({
"Datum": cl.datum.strftime("%Y-%m-%d"),
"Autoren": [autor.name for autor in cl.autoren.all()],
"Aenderung": cl.aenderung
})
doc_data["Changelog"] = changelog_entries
# Process Vorgaben for this document
vorgaben = dokument.vorgaben.all().order_by('order')
for vorgabe in vorgaben:
# Get Kurztext and Langtext
kurztext_sections = []
for kt in vorgabe.vorgabekurztext_set.all().order_by('order'):
kurztext_sections.append({
"typ": kt.abschnitttyp.abschnitttyp if kt.abschnitttyp else "text",
"inhalt": kt.inhalt
})
langtext_sections = []
for lt in vorgabe.vorgabelangtext_set.all().order_by('order'):
langtext_sections.append({
"typ": lt.abschnitttyp.abschnitttyp if lt.abschnitttyp else "text",
"inhalt": lt.inhalt
})
# Build text structures following Langtext pattern
kurztext = {
"Abschnitt": kurztext_sections if kurztext_sections else []
} if kurztext_sections else {}
langtext = {
"Abschnitt": langtext_sections if langtext_sections else []
} if langtext_sections else {}
# Get references and keywords
referenzen = [f"{ref.name_nummer}: {ref.name_text}" if ref.name_text else ref.name_nummer for ref in vorgabe.referenzen.all()]
stichworte = [stw.stichwort for stw in vorgabe.stichworte.all()]
# Get checklist questions
checklistenfragen = [cf.frage for cf in vorgabe.checklistenfragen.all()]
vorgabe_data = {
"Nummer": str(vorgabe.nummer),
"Titel": vorgabe.titel,
"Thema": vorgabe.thema.name if vorgabe.thema else "",
"Kurztext": kurztext,
"Langtext": langtext,
"Referenz": referenzen,
"Gueltigkeit": {
"Von": vorgabe.gueltigkeit_von.strftime("%Y-%m-%d") if vorgabe.gueltigkeit_von else "",
"Bis": vorgabe.gueltigkeit_bis.strftime("%Y-%m-%d") if vorgabe.gueltigkeit_bis else None
},
"Checklistenfragen": checklistenfragen,
"Stichworte": stichworte
}
doc_data["Vorgaben"].append(vorgabe_data)
output_data.append(doc_data)
# Output the data
json_output = json.dumps(output_data, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False)
if options['output']:
with open(options['output'], 'w', encoding='utf-8') as f:
f.write(json_output)
self.stdout.write(self.style.SUCCESS(f'JSON exported to {options["output"]}'))
else:
self.stdout.write(json_output)

View File

@@ -0,0 +1,70 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from dokumente.models import Vorgabe
import datetime
class Command(BaseCommand):
help = 'Run sanity checks on Vorgaben to detect conflicts'
def add_arguments(self, parser):
parser.add_argument(
'--fix',
action='store_true',
help='Attempt to fix conflicts (not implemented yet)',
)
parser.add_argument(
'--verbose',
action='store_true',
help='Show detailed output',
)
def handle(self, *args, **options):
self.verbose = options['verbose']
self.stdout.write(self.style.SUCCESS('Starting Vorgaben sanity check...'))
# Run the sanity check
conflicts = Vorgabe.sanity_check_vorgaben()
if not conflicts:
self.stdout.write(self.style.SUCCESS('✓ No conflicts found in Vorgaben'))
return
self.stdout.write(
self.style.WARNING(f'Found {len(conflicts)} conflicts:')
)
for i, conflict in enumerate(conflicts, 1):
self._display_conflict(i, conflict)
if options['fix']:
self.stdout.write(self.style.ERROR('Auto-fix not implemented yet'))
def _display_conflict(self, index, conflict):
"""Display a single conflict"""
v1 = conflict['vorgabe1']
v2 = conflict['vorgabe2']
self.stdout.write(f"\n{index}. {conflict['message']}")
if self.verbose:
self.stdout.write(f" Vorgabe 1: {v1.Vorgabennummer()}")
self.stdout.write(f" Valid from: {v1.gueltigkeit_von} to {v1.gueltigkeit_bis or 'unlimited'}")
self.stdout.write(f" Title: {v1.titel}")
self.stdout.write(f" Vorgabe 2: {v2.Vorgabennummer()}")
self.stdout.write(f" Valid from: {v2.gueltigkeit_von} to {v2.gueltigkeit_bis or 'unlimited'}")
self.stdout.write(f" Title: {v2.titel}")
# Show the overlapping period
overlap_start = max(v1.gueltigkeit_von, v2.gueltigkeit_von)
overlap_end = min(
v1.gueltigkeit_bis or datetime.date.max,
v2.gueltigkeit_bis or datetime.date.max
)
if overlap_end != datetime.date.max:
self.stdout.write(f" Overlap: {overlap_start} to {overlap_end}")
else:
self.stdout.write(f" Overlap starts: {overlap_start} (no end)")

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ class Dokument(models.Model):
gueltigkeit_bis = models.DateField(null=True, blank=True)
signatur_cso = models.CharField(max_length=255, blank=True)
anhaenge = models.TextField(blank=True)
aktiv = models.BooleanField(blank=True)
def __str__(self):
return f"{self.nummer} {self.name}"
@@ -56,9 +57,10 @@ class Dokument(models.Model):
verbose_name="Dokument"
class Vorgabe(models.Model):
order = models.IntegerField()
nummer = models.IntegerField()
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='vorgaben')
thema = models.ForeignKey(Thema, on_delete=models.PROTECT)
thema = models.ForeignKey(Thema, on_delete=models.PROTECT, blank=False)
titel = models.CharField(max_length=255)
referenzen = models.ManyToManyField(Referenz, blank=True)
gueltigkeit_von = models.DateField()
@@ -76,7 +78,7 @@ class Vorgabe(models.Model):
if not self.gueltigkeit_bis:
return "active"
if self.gueltigkeit_bis > check_date:
if self.gueltigkeit_bis >= check_date:
return "active"
return "expired" if not verbose else "Ist seit dem "+self.gueltigkeit_bis.strftime('%d.%m.%Y')+" nicht mehr in Kraft."
@@ -84,9 +86,126 @@ class Vorgabe(models.Model):
def __str__(self):
return f"{self.Vorgabennummer()}: {self.titel}"
@staticmethod
def sanity_check_vorgaben():
"""
Sanity check for Vorgaben:
If there are two Vorgaben with the same number, Thema and Dokument,
their valid_from and valid_to date ranges shouldn't intersect.
Returns:
list: List of dictionaries containing conflicts found
"""
conflicts = []
# Group Vorgaben by dokument, thema, and nummer
from django.db.models import Count
from itertools import combinations
# Find Vorgaben with same dokument, thema, and nummer
duplicate_groups = (
Vorgabe.objects.values('dokument', 'thema', 'nummer')
.annotate(count=Count('id'))
.filter(count__gt=1)
)
for group in duplicate_groups:
# Get all Vorgaben in this group
vorgaben = Vorgabe.objects.filter(
dokument=group['dokument'],
thema=group['thema'],
nummer=group['nummer']
)
# Check all pairs for date range intersections
for vorgabe1, vorgabe2 in combinations(vorgaben, 2):
if Vorgabe._date_ranges_intersect(
vorgabe1.gueltigkeit_von, vorgabe1.gueltigkeit_bis,
vorgabe2.gueltigkeit_von, vorgabe2.gueltigkeit_bis
):
conflicts.append({
'vorgabe1': vorgabe1,
'vorgabe2': vorgabe2,
'conflict_type': 'date_range_intersection',
'message': f"Vorgaben {vorgabe1.Vorgabennummer()} and {vorgabe2.Vorgabennummer()} "
f"überschneiden sich in der Geltungsdauer"
})
return conflicts
def clean(self):
"""
Validate the Vorgabe before saving.
"""
from django.core.exceptions import ValidationError
# Check for conflicts with existing Vorgaben
conflicts = self.find_conflicts()
if conflicts:
conflict_messages = [c['message'] for c in conflicts]
raise ValidationError({
'__all__': conflict_messages
})
def find_conflicts(self):
"""
Find conflicts with existing Vorgaben.
Returns:
list: List of conflict dictionaries
"""
conflicts = []
# Find Vorgaben with same dokument, thema, and nummer (excluding self)
existing_vorgaben = Vorgabe.objects.filter(
dokument=self.dokument,
thema=self.thema,
nummer=self.nummer
).exclude(pk=self.pk)
for other_vorgabe in existing_vorgaben:
if self._date_ranges_intersect(
self.gueltigkeit_von, self.gueltigkeit_bis,
other_vorgabe.gueltigkeit_von, other_vorgabe.gueltigkeit_bis
):
conflicts.append({
'vorgabe1': self,
'vorgabe2': other_vorgabe,
'conflict_type': 'date_range_intersection',
'message': f"Vorgabe {self.Vorgabennummer()} in Konflikt mit "
f"bestehender {other_vorgabe.Vorgabennummer()} "
f" - Geltungsdauer übeschneidet sich"
})
return conflicts
@staticmethod
def _date_ranges_intersect(start1, end1, start2, end2):
"""
Check if two date ranges intersect.
None end date means open-ended range.
Args:
start1, start2: Start dates
end1, end2: End dates (can be None for open-ended)
Returns:
bool: True if ranges intersect
"""
# If either start date is None, treat it as invalid case
if not start1 or not start2:
return False
# If end date is None, treat it as far future
end1 = end1 or datetime.date.max
end2 = end2 or datetime.date.max
# Ranges intersect if start1 <= end2 and start2 <= end1
return start1 <= end2 and start2 <= end1
class Meta:
verbose_name_plural="Vorgaben"
ordering = ['order']
class VorgabeLangtext(Textabschnitt):
abschnitt=models.ForeignKey(Vorgabe,on_delete=models.CASCADE)
@@ -123,6 +242,12 @@ class Checklistenfrage(models.Model):
verbose_name_plural="Fragen für Checkliste"
verbose_name="Frage für Checkliste"
class VorgabenTable(Vorgabe):
class Meta:
proxy = True
verbose_name = "Vorgabe (Tabellenansicht)"
verbose_name_plural = "Vorgaben (Tabellenansicht)"
class Changelog(models.Model):
dokument = models.ForeignKey(Dokument, on_delete=models.CASCADE, related_name='changelog')
autoren = models.ManyToManyField(Person)

View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block content %}
<h1 class="mb-4">Unvollständige Vorgaben</h1>
{% if vorgaben_data %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Vorgabe</th>
<th class="text-center">Referenzen</th>
<th class="text-center">Stichworte</th>
<th class="text-center">Text</th>
<th class="text-center">Checklistenfragen</th>
</tr>
</thead>
<tbody>
{% for item in vorgaben_data %}
<tr>
<td>
<a href="/autorenumgebung/dokumente/vorgabe/{{ item.vorgabe.id }}/change/"
class="text-decoration-none" target="_blank">
<strong>{{ item.vorgabe.Vorgabennummer }}</strong><br>
<small class="text-muted">{{ item.vorgabe.titel }}</small><br>
<small class="text-muted">{{ item.vorgabe.dokument.nummer }} {{ item.vorgabe.dokument.name }}</small>
</a>
</td>
<td class="text-center align-middle">
{% if item.has_references %}
<span class="text-success fs-4"></span>
{% else %}
<span class="text-danger fs-4"></span>
{% endif %}
</td>
<td class="text-center align-middle">
{% if item.has_stichworte %}
<span class="text-success fs-4"></span>
{% else %}
<span class="text-danger fs-4"></span>
{% endif %}
</td>
<td class="text-center align-middle">
{% if item.has_text %}
<span class="text-success fs-4"></span>
{% else %}
<span class="text-danger fs-4"></span>
{% endif %}
</td>
<td class="text-center align-middle">
{% if item.has_checklistenfragen %}
<span class="text-success fs-4"></span>
{% else %}
<span class="text-danger fs-4"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Summary -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Zusammenfassung</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-3">
<div class="p-3">
<h4 class="text-danger" id="no-references-count">0</h4>
<p class="mb-0">Ohne Referenzen</p>
</div>
</div>
<div class="col-md-3">
<div class="p-3">
<h4 class="text-danger" id="no-stichworte-count">0</h4>
<p class="mb-0">Ohne Stichworte</p>
</div>
</div>
<div class="col-md-3">
<div class="p-3">
<h4 class="text-danger" id="no-text-count">0</h4>
<p class="mb-0">Ohne Text</p>
</div>
</div>
<div class="col-md-3">
<div class="p-3">
<h4 class="text-danger" id="no-checklistenfragen-count">0</h4>
<p class="mb-0">Ohne Checklistenfragen</p>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 text-center">
<h4 class="text-primary">Gesamt: {{ vorgaben_data|length }} unvollständige Vorgaben</h4>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-success" role="alert">
<h4 class="alert-heading">
<i class="fas fa-check-circle"></i> Alle Vorgaben sind vollständig!
</h4>
<p>Alle Vorgaben haben Referenzen, Stichworte, Text und Checklistenfragen.</p>
<hr>
<p class="mb-0">
<a href="{% url 'standard_list' %}" class="btn btn-primary">
<i class="fas fa-list"></i> Zurück zur Übersicht
</a>
</p>
</div>
{% endif %}
<div class="mt-3">
<a href="{% url 'standard_list' %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
</a>
</div>
<script>
// Update summary counts
document.addEventListener('DOMContentLoaded', function() {
let noReferences = 0;
let noStichworte = 0;
let noText = 0;
let noChecklistenfragen = 0;
const rows = document.querySelectorAll('tbody tr');
rows.forEach(function(row) {
const cells = row.querySelectorAll('td');
if (cells.length >= 5) {
if (cells[1].textContent.trim() === '✗') noReferences++;
if (cells[2].textContent.trim() === '✗') noStichworte++;
if (cells[3].textContent.trim() === '✗') noText++;
if (cells[4].textContent.trim() === '✗') noChecklistenfragen++;
}
});
document.getElementById('no-references-count').textContent = noReferences;
document.getElementById('no-stichworte-count').textContent = noStichworte;
document.getElementById('no-text-count').textContent = noText;
document.getElementById('no-checklistenfragen-count').textContent = noChecklistenfragen;
});
</script>
{% endblock %}

View File

@@ -8,7 +8,8 @@
<!-- Autoren, Prüfende etc. -->
<p><strong>Autoren:</strong> {{ standard.autoren.all|join:", " }}</p>
<p><strong>Prüfende:</strong> {{ standard.pruefende.all|join:", " }}</p>
<p><strong>Gültigkeit:</strong> {{ standard.gueltigkeit_von }} bis {{ standard.gueltigkeit_bis }}</p>
<p><strong>Gültigkeit:</strong> {{ standard.gueltigkeit_von }} bis {{ standard.gueltigkeit_bis|default_if_none:"auf weiteres" }}</p>
<p><a href="{% url 'standard_json' standard.nummer %}" class="button" download="{{ standard.nummer }}.json">JSON herunterladen</a></p>
<!-- Start Einleitung -->
{% if standard.einleitung_html %}

385
dokumente/test_json.py Normal file
View File

@@ -0,0 +1,385 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.core.management import call_command
from datetime import date
from io import StringIO
import tempfile
import os
import json
from dokumente.models import (
Dokumententyp, Person, Thema, Dokument, Vorgabe,
VorgabeLangtext, VorgabeKurztext, Geltungsbereich,
Einleitung, Checklistenfrage, Changelog
)
from abschnitte.models import AbschnittTyp
class JSONExportManagementCommandTest(TestCase):
"""Test cases for export_json management command"""
def setUp(self):
"""Set up test data for JSON export"""
# Create test data
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.autor1 = Person.objects.create(
name="Max Mustermann",
funktion="Security Analyst"
)
self.autor2 = Person.objects.create(
name="Erika Mustermann",
funktion="Security Manager"
)
self.thema = Thema.objects.create(
name="Access Control",
erklaerung="Zugangskontrolle"
)
self.dokument = Dokument.objects.create(
nummer="TEST-001",
dokumententyp=self.dokumententyp,
name="Test Standard",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31),
signatur_cso="CSO-123",
anhaenge="Anhang1.pdf, Anhang2.pdf",
aktiv=True
)
self.dokument.autoren.add(self.autor1, self.autor2)
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="Test Vorgabe",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31)
)
# Create text sections
self.abschnitttyp_text = AbschnittTyp.objects.create(abschnitttyp="text")
self.abschnitttyp_table = AbschnittTyp.objects.create(abschnitttyp="table")
self.geltungsbereich = Geltungsbereich.objects.create(
geltungsbereich=self.dokument,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist der Geltungsbereich",
order=1
)
self.einleitung = Einleitung.objects.create(
einleitung=self.dokument,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist die Einleitung",
order=1
)
self.kurztext = VorgabeKurztext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist der Kurztext",
order=1
)
self.langtext = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_table,
inhalt="Spalte1|Spalte2\nWert1|Wert2",
order=1
)
self.checklistenfrage = Checklistenfrage.objects.create(
vorgabe=self.vorgabe,
frage="Ist die Zugriffskontrolle implementiert?"
)
self.changelog = Changelog.objects.create(
dokument=self.dokument,
datum=date(2023, 6, 1),
aenderung="Erste Version erstellt"
)
self.changelog.autoren.add(self.autor1)
def test_export_json_command_stdout(self):
"""Test export_json command output to stdout"""
out = StringIO()
call_command('export_json', stdout=out)
output = out.getvalue()
# Check that output contains expected JSON structure
self.assertIn('"Typ": "Standard IT-Sicherheit"', output)
self.assertIn('"Nummer": "TEST-001"', output)
self.assertIn('"Name": "Test Standard"', output)
self.assertIn('"Max Mustermann"', output)
self.assertIn('"Erika Mustermann"', output)
self.assertIn('"Von": "2023-01-01"', output)
self.assertIn('"Bis": "2025-12-31"', output)
self.assertIn('"SignaturCSO": "CSO-123"', output)
self.assertIn('"Dies ist der Geltungsbereich"', output)
self.assertIn('"Dies ist die Einleitung"', output)
self.assertIn('"Dies ist der Kurztext"', output)
self.assertIn('"Ist die Zugriffskontrolle implementiert?"', output)
self.assertIn('"Erste Version erstellt"', output)
def test_export_json_command_to_file(self):
"""Test export_json command output to file"""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as tmp_file:
tmp_filename = tmp_file.name
try:
call_command('export_json', output=tmp_filename)
# Read file content
with open(tmp_filename, 'r', encoding='utf-8') as f:
content = f.read()
# Parse JSON to ensure it's valid
data = json.loads(content)
# Verify structure
self.assertIsInstance(data, list)
self.assertEqual(len(data), 1)
doc_data = data[0]
self.assertEqual(doc_data['Nummer'], 'TEST-001')
self.assertEqual(doc_data['Name'], 'Test Standard')
self.assertEqual(doc_data['Typ'], 'Standard IT-Sicherheit')
self.assertEqual(len(doc_data['Autoren']), 2)
self.assertIn('Max Mustermann', doc_data['Autoren'])
self.assertIn('Erika Mustermann', doc_data['Autoren'])
finally:
# Clean up temporary file
if os.path.exists(tmp_filename):
os.unlink(tmp_filename)
def test_export_json_command_empty_database(self):
"""Test export_json command with no documents"""
# Delete all documents
Dokument.objects.all().delete()
out = StringIO()
call_command('export_json', stdout=out)
output = out.getvalue()
# Should output empty array
self.assertEqual(output.strip(), '[]')
def test_export_json_command_inactive_documents(self):
"""Test export_json command filters inactive documents"""
# Create inactive document
inactive_doc = Dokument.objects.create(
nummer="INACTIVE-001",
dokumententyp=self.dokumententyp,
name="Inactive Document",
aktiv=False
)
out = StringIO()
call_command('export_json', stdout=out)
output = out.getvalue()
# Should not contain inactive document
self.assertNotIn('"INACTIVE-001"', output)
self.assertNotIn('"Inactive Document"', output)
# Should still contain active document
self.assertIn('"TEST-001"', output)
self.assertIn('"Test Standard"', output)
class StandardJSONViewTest(TestCase):
"""Test cases for standard_json view"""
def setUp(self):
"""Set up test data for JSON view"""
self.client = Client()
# Create test data
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.autor = Person.objects.create(
name="Test Autor",
funktion="Security Analyst"
)
self.pruefender = Person.objects.create(
name="Test Pruefender",
funktion="Security Manager"
)
self.thema = Thema.objects.create(
name="Access Control",
erklaerung="Zugangskontrolle"
)
self.dokument = Dokument.objects.create(
nummer="JSON-001",
dokumententyp=self.dokumententyp,
name="JSON Test Standard",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31),
signatur_cso="CSO-456",
anhaenge="test.pdf",
aktiv=True
)
self.dokument.autoren.add(self.autor)
self.dokument.pruefende.add(self.pruefender)
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="JSON Test Vorgabe",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31)
)
# Create text sections
self.abschnitttyp_text = AbschnittTyp.objects.create(abschnitttyp="text")
self.geltungsbereich = Geltungsbereich.objects.create(
geltungsbereich=self.dokument,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist der Geltungsbereich",
order=1
)
self.einleitung = Einleitung.objects.create(
einleitung=self.dokument,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist die Einleitung",
order=1
)
self.kurztext = VorgabeKurztext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_text,
inhalt="JSON Kurztext",
order=1
)
self.langtext = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_text,
inhalt="JSON Langtext",
order=1
)
self.checklistenfrage = Checklistenfrage.objects.create(
vorgabe=self.vorgabe,
frage="JSON Checklistenfrage?"
)
self.changelog = Changelog.objects.create(
dokument=self.dokument,
datum=date(2023, 6, 1),
aenderung="JSON Changelog Eintrag"
)
self.changelog.autoren.add(self.autor)
def test_standard_json_view_success(self):
"""Test standard_json view returns correct JSON"""
url = reverse('standard_json', kwargs={'nummer': 'JSON-001'})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Parse JSON response
data = json.loads(response.content)
# Verify document structure
self.assertEqual(data['Nummer'], 'JSON-001')
self.assertEqual(data['Name'], 'JSON Test Standard')
self.assertEqual(data['Typ'], 'Standard IT-Sicherheit')
self.assertEqual(len(data['Autoren']), 1)
self.assertEqual(data['Autoren'][0], 'Test Autor')
self.assertEqual(len(data['Pruefende']), 1)
self.assertEqual(data['Pruefende'][0], 'Test Pruefender')
self.assertEqual(data['Gueltigkeit']['Von'], '2023-01-01')
self.assertEqual(data['Gueltigkeit']['Bis'], '2025-12-31')
self.assertEqual(data['SignaturCSO'], 'CSO-456')
self.assertEqual(data['Anhänge'], 'test.pdf')
self.assertEqual(data['Verantwortlich'], 'Information Security Management BIT')
self.assertIsNone(data['Klassifizierung'])
def test_standard_json_view_not_found(self):
"""Test standard_json view returns 404 for non-existent document"""
url = reverse('standard_json', kwargs={'nummer': 'NONEXISTENT'})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_standard_json_view_empty_sections(self):
"""Test standard_json view handles empty sections correctly"""
# Create document without sections
empty_doc = Dokument.objects.create(
nummer="EMPTY-001",
dokumententyp=self.dokumententyp,
name="Empty Document",
aktiv=True
)
url = reverse('standard_json', kwargs={'nummer': 'EMPTY-001'})
response = self.client.get(url)
data = json.loads(response.content)
# Verify empty sections are handled correctly
self.assertEqual(data['Geltungsbereich'], {})
self.assertEqual(data['Einleitung'], {})
self.assertEqual(data['Vorgaben'], [])
self.assertEqual(data['Changelog'], [])
def test_standard_json_view_null_dates(self):
"""Test standard_json view handles null dates correctly"""
# Create document with null dates
null_doc = Dokument.objects.create(
nummer="NULL-001",
dokumententyp=self.dokumententyp,
name="Null Dates Document",
gueltigkeit_von=None,
gueltigkeit_bis=None,
aktiv=True
)
url = reverse('standard_json', kwargs={'nummer': 'NULL-001'})
response = self.client.get(url)
data = json.loads(response.content)
# Verify null dates are handled correctly
self.assertEqual(data['Gueltigkeit']['Von'], '')
self.assertIsNone(data['Gueltigkeit']['Bis'])
def test_standard_json_view_json_formatting(self):
"""Test standard_json view returns properly formatted JSON"""
url = reverse('standard_json', kwargs={'nummer': 'JSON-001'})
response = self.client.get(url)
# Check that response is valid JSON
try:
data = json.loads(response.content)
json_valid = True
except json.JSONDecodeError:
json_valid = False
self.assertTrue(json_valid)
# Check that JSON is properly indented (should be formatted)
self.assertIn('\n', response.content.decode())
self.assertIn(' ', response.content.decode()) # Check for indentation

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,11 @@ from . import views
urlpatterns = [
path('', views.standard_list, name='standard_list'),
path('unvollstaendig/', views.incomplete_vorgaben, name='incomplete_vorgaben'),
path('<str:nummer>/', views.standard_detail, name='standard_detail'),
path('<str:nummer>/history/<str:check_date>/', views.standard_detail),
path('<str:nummer>/history/', views.standard_detail, {"check_date":"today"}, name='standard_history'),
path('<str:nummer>/checkliste/', views.standard_checkliste, name='standard_checkliste')
path('<str:nummer>/checkliste/', views.standard_checkliste, name='standard_checkliste'),
path('<str:nummer>/json/', views.standard_json, name='standard_json')
]

123
dokumente/utils.py Normal file
View File

@@ -0,0 +1,123 @@
"""
Utility functions for Vorgaben sanity checking
"""
import datetime
from django.db.models import Count
from itertools import combinations
from dokumente.models import Vorgabe
def check_vorgabe_conflicts():
"""
Check for conflicts in Vorgaben.
Main rule: If there are two Vorgaben with the same number, Thema and Dokument,
their valid_from and valid_to date ranges shouldn't intersect.
Returns:
list: List of conflict dictionaries
"""
conflicts = []
# Find Vorgaben with same dokument, thema, and nummer
duplicate_groups = (
Vorgabe.objects.values('dokument', 'thema', 'nummer')
.annotate(count=Count('id'))
.filter(count__gt=1)
)
for group in duplicate_groups:
# Get all Vorgaben in this group
vorgaben = Vorgabe.objects.filter(
dokument=group['dokument'],
thema=group['thema'],
nummer=group['nummer']
)
# Check all pairs for date range intersections
for vorgabe1, vorgabe2 in combinations(vorgaben, 2):
if date_ranges_intersect(
vorgabe1.gueltigkeit_von, vorgabe1.gueltigkeit_bis,
vorgabe2.gueltigkeit_von, vorgabe2.gueltigkeit_bis
):
conflicts.append({
'vorgabe1': vorgabe1,
'vorgabe2': vorgabe2,
'conflict_type': 'date_range_intersection',
'message': f"Vorgaben {vorgabe1.Vorgabennummer()} and {vorgabe2.Vorgabennummer()} "
f"have intersecting validity periods"
})
return conflicts
def date_ranges_intersect(start1, end1, start2, end2):
"""
Check if two date ranges intersect.
None end date means open-ended range.
Args:
start1, start2: Start dates
end1, end2: End dates (can be None for open-ended)
Returns:
bool: True if ranges intersect
"""
# If either start date is None, treat it as invalid case
if not start1 or not start2:
return False
# If end date is None, treat it as far future
end1 = end1 or datetime.date.max
end2 = end2 or datetime.date.max
# Ranges intersect if start1 <= end2 and start2 <= end1
return start1 <= end2 and start2 <= end1
def format_conflict_report(conflicts, verbose=False):
"""
Format conflicts into a readable report.
Args:
conflicts: List of conflict dictionaries
verbose: Whether to show detailed information
Returns:
str: Formatted report
"""
if not conflicts:
return "✓ No conflicts found in Vorgaben"
lines = [f"Found {len(conflicts)} conflicts:"]
for i, conflict in enumerate(conflicts, 1):
lines.append(f"\n{i}. {conflict['message']}")
if verbose:
v1 = conflict['vorgabe1']
v2 = conflict['vorgabe2']
lines.append(f" Vorgabe 1: {v1.Vorgabennummer()}")
lines.append(f" Valid from: {v1.gueltigkeit_von} to {v1.gueltigkeit_bis or 'unlimited'}")
lines.append(f" Title: {v1.titel}")
lines.append(f" Vorgabe 2: {v2.Vorgabennummer()}")
lines.append(f" Valid from: {v2.gueltigkeit_von} to {v2.gueltigkeit_bis or 'unlimited'}")
lines.append(f" Title: {v2.titel}")
# Show the overlapping period
v1 = conflict['vorgabe1']
v2 = conflict['vorgabe2']
overlap_start = max(v1.gueltigkeit_von, v2.gueltigkeit_von)
overlap_end = min(
v1.gueltigkeit_bis or datetime.date.max,
v2.gueltigkeit_bis or datetime.date.max
)
if overlap_end != datetime.date.max:
lines.append(f" Overlap: {overlap_start} to {overlap_end}")
else:
lines.append(f" Overlap starts: {overlap_start} (no end)")
return "\n".join(lines)

View File

@@ -1,5 +1,9 @@
from django.shortcuts import render, get_object_or_404
from .models import Dokument
from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import JsonResponse
from django.core.serializers.json import DjangoJSONEncoder
import json
from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage
from abschnitte.utils import render_textabschnitte
from datetime import date
@@ -56,3 +60,180 @@ def standard_checkliste(request, nummer):
})
def is_staff_user(user):
return user.is_staff
@login_required
@user_passes_test(is_staff_user)
def incomplete_vorgaben(request):
"""
Show table of all Vorgaben with completeness status:
- References (✓ or ✗)
- Stichworte (✓ or ✗)
- Text (✓ or ✗)
- Checklistenfragen (✓ or ✗)
"""
# Get all active Vorgaben
all_vorgaben = Vorgabe.objects.all().select_related('dokument', 'thema').prefetch_related(
'referenzen', 'stichworte', 'checklistenfragen', 'vorgabekurztext_set', 'vorgabelangtext_set'
)
# Build table data
vorgaben_data = []
for vorgabe in all_vorgaben:
has_references = vorgabe.referenzen.exists()
has_stichworte = vorgabe.stichworte.exists()
has_kurztext = vorgabe.vorgabekurztext_set.exists()
has_langtext = vorgabe.vorgabelangtext_set.exists()
has_text = has_kurztext or has_langtext
has_checklistenfragen = vorgabe.checklistenfragen.exists()
# Only include Vorgaben that are incomplete in at least one way
if not (has_references and has_stichworte and has_text and has_checklistenfragen):
vorgaben_data.append({
'vorgabe': vorgabe,
'has_references': has_references,
'has_stichworte': has_stichworte,
'has_text': has_text,
'has_checklistenfragen': has_checklistenfragen,
'is_complete': has_references and has_stichworte and has_text and has_checklistenfragen
})
# Sort by document number and Vorgabe number
vorgaben_data.sort(key=lambda x: (x['vorgabe'].dokument.nummer, x['vorgabe'].Vorgabennummer()))
return render(request, 'standards/incomplete_vorgaben.html', {
'vorgaben_data': vorgaben_data,
})
def standard_json(request, nummer):
"""
Export a single Dokument as JSON
"""
# Get the document with all related data
dokument = get_object_or_404(
Dokument.objects.prefetch_related(
'autoren', 'pruefende', 'vorgaben__thema',
'vorgaben__referenzen', 'vorgaben__stichworte',
'vorgaben__checklistenfragen', 'vorgaben__vorgabekurztext_set',
'vorgaben__vorgabelangtext_set', 'geltungsbereich_set',
'einleitung_set', 'changelog__autoren'
),
nummer=nummer
)
# Build document structure (reusing logic from export_json command)
doc_data = {
"Typ": dokument.dokumententyp.name if dokument.dokumententyp else "",
"Nummer": dokument.nummer,
"Name": dokument.name,
"Autoren": [autor.name for autor in dokument.autoren.all()],
"Pruefende": [pruefender.name for pruefender in dokument.pruefende.all()],
"Gueltigkeit": {
"Von": dokument.gueltigkeit_von.strftime("%Y-%m-%d") if dokument.gueltigkeit_von else "",
"Bis": dokument.gueltigkeit_bis.strftime("%Y-%m-%d") if dokument.gueltigkeit_bis else None
},
"SignaturCSO": dokument.signatur_cso,
"Geltungsbereich": {},
"Einleitung": {},
"Ziel": "",
"Grundlagen": "",
"Changelog": [],
"Anhänge": dokument.anhaenge,
"Verantwortlich": "Information Security Management BIT",
"Klassifizierung": None,
"Glossar": {},
"Vorgaben": []
}
# Process Geltungsbereich sections
geltungsbereich_sections = []
for gb in dokument.geltungsbereich_set.all().order_by('order'):
geltungsbereich_sections.append({
"typ": gb.abschnitttyp.abschnitttyp if gb.abschnitttyp else "text",
"inhalt": gb.inhalt
})
if geltungsbereich_sections:
doc_data["Geltungsbereich"] = {
"Abschnitt": geltungsbereich_sections
}
# Process Einleitung sections
einleitung_sections = []
for ei in dokument.einleitung_set.all().order_by('order'):
einleitung_sections.append({
"typ": ei.abschnitttyp.abschnitttyp if ei.abschnitttyp else "text",
"inhalt": ei.inhalt
})
if einleitung_sections:
doc_data["Einleitung"] = {
"Abschnitt": einleitung_sections
}
# Process Changelog entries
changelog_entries = []
for cl in dokument.changelog.all().order_by('-datum'):
changelog_entries.append({
"Datum": cl.datum.strftime("%Y-%m-%d"),
"Autoren": [autor.name for autor in cl.autoren.all()],
"Aenderung": cl.aenderung
})
doc_data["Changelog"] = changelog_entries
# Process Vorgaben for this document
vorgaben = dokument.vorgaben.all().order_by('order')
for vorgabe in vorgaben:
# Get Kurztext and Langtext sections
kurztext_sections = []
for kt in vorgabe.vorgabekurztext_set.all().order_by('order'):
kurztext_sections.append({
"typ": kt.abschnitttyp.abschnitttyp if kt.abschnitttyp else "text",
"inhalt": kt.inhalt
})
langtext_sections = []
for lt in vorgabe.vorgabelangtext_set.all().order_by('order'):
langtext_sections.append({
"typ": lt.abschnitttyp.abschnitttyp if lt.abschnitttyp else "text",
"inhalt": lt.inhalt
})
# Build text structures following Langtext pattern
kurztext = {
"Abschnitt": kurztext_sections if kurztext_sections else []
} if kurztext_sections else {}
langtext = {
"Abschnitt": langtext_sections if langtext_sections else []
} if langtext_sections else {}
# Get references and keywords
referenzen = [f"{ref.name_nummer}: {ref.name_text}" if ref.name_text else ref.name_nummer for ref in vorgabe.referenzen.all()]
stichworte = [stw.stichwort for stw in vorgabe.stichworte.all()]
# Get checklist questions
checklistenfragen = [cf.frage for cf in vorgabe.checklistenfragen.all()]
vorgabe_data = {
"Nummer": str(vorgabe.nummer),
"Titel": vorgabe.titel,
"Thema": vorgabe.thema.name if vorgabe.thema else "",
"Kurztext": kurztext,
"Langtext": langtext,
"Referenz": referenzen,
"Gueltigkeit": {
"Von": vorgabe.gueltigkeit_von.strftime("%Y-%m-%d") if vorgabe.gueltigkeit_von else "",
"Bis": vorgabe.gueltigkeit_bis.strftime("%Y-%m-%d") if vorgabe.gueltigkeit_bis else None
},
"Checklistenfragen": checklistenfragen,
"Stichworte": stichworte
}
doc_data["Vorgaben"].append(vorgabe_data)
# Return JSON response
return JsonResponse(doc_data, json_dumps_params={'indent': 2, 'ensure_ascii': False}, encoder=DjangoJSONEncoder)

View File

@@ -17,6 +17,9 @@
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<a class="nav-item nav-link active" href="/dokumente">Standards</a>
{% if user.is_staff %}
<a class="nav-item nav-link" href="/dokumente/unvollstaendig/">Unvollständig</a>
{% endif %}
<a class="nav-item nav-link" href="/referenzen">Referenzen</a>
<a class="nav-item nav-link" href="/stichworte">Stichworte</a>
<a class="nav-item nav-link" href="/search">Suche</a>
@@ -28,6 +31,6 @@
<div class="flex-fill">{% block content %}Main Content{% endblock %}</div>
<div class="col-md-2">{% block sidebar_right %}{% endblock %}</div>
</div>
<div>VorgabenUI v0.931</div>
<div>VorgabenUI v0.945</div>
</body>
</html>

View File

@@ -2,6 +2,12 @@
{% block content %}
<h1 class="mb-4">Suche</h1>
{% if error_message %}
<div class="alert alert-danger">
<strong>Fehler:</strong> {{ error_message }}
</div>
{% endif %}
<!-- Search form -->
<form action="." method="post">
{% csrf_token %}
@@ -13,7 +19,9 @@
id="query"
name="q"
placeholder="Suchbegriff eingeben …"
required>
value="{{ search_term|default:'' }}"
required
maxlength="200">
</div>
<button type="submit" class="btn btn-primary">Suchen</button>
</form>

312
pages/tests.py Normal file
View File

@@ -0,0 +1,312 @@
from django.test import TestCase, Client
from django.core.exceptions import ValidationError
from django.utils import timezone
from datetime import date, timedelta
from dokumente.models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Geltungsbereich, Dokumententyp, Thema
from stichworte.models import Stichwort
from unittest.mock import patch
import re
class SearchViewTest(TestCase):
def setUp(self):
self.client = Client()
# Create test data
self.dokumententyp = Dokumententyp.objects.create(
name="Test Typ",
verantwortliche_ve="Test VE"
)
self.thema = Thema.objects.create(
name="Test Thema",
erklaerung="Test Erklärung"
)
self.dokument = Dokument.objects.create(
nummer="TEST-001",
dokumententyp=self.dokumententyp,
name="Test Dokument",
gueltigkeit_von=date.today(),
aktiv=True
)
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="Test Vorgabe Titel",
gueltigkeit_von=date.today()
)
# Create text content
self.kurztext = VorgabeKurztext.objects.create(
abschnitt=self.vorgabe,
inhalt="Dies ist ein Test Kurztext mit Suchbegriff"
)
self.langtext = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
inhalt="Dies ist ein Test Langtext mit anderem Suchbegriff"
)
self.geltungsbereich = Geltungsbereich.objects.create(
geltungsbereich=self.dokument,
inhalt="Test Geltungsbereich mit Suchbegriff"
)
def test_search_get_request(self):
"""Test GET request returns search form"""
response = self.client.get('/search/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Suche')
self.assertContains(response, 'Suchbegriff')
def test_search_post_valid_term(self):
"""Test POST request with valid search term"""
response = self.client.post('/search/', {'q': 'Test'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Suchresultate für Test')
def test_search_case_insensitive(self):
"""Test that search is case insensitive"""
# Search for lowercase
response = self.client.post('/search/', {'q': 'test'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Suchresultate für test')
# Search for uppercase
response = self.client.post('/search/', {'q': 'TEST'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Suchresultate für TEST')
# Search for mixed case
response = self.client.post('/search/', {'q': 'TeSt'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Suchresultate für TeSt')
def test_search_in_kurztext(self):
"""Test search in Kurztext content"""
response = self.client.post('/search/', {'q': 'Suchbegriff'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'TEST-001')
def test_search_in_langtext(self):
"""Test search in Langtext content"""
response = self.client.post('/search/', {'q': 'anderem'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'TEST-001')
def test_search_in_titel(self):
"""Test search in Vorgabe title"""
response = self.client.post('/search/', {'q': 'Titel'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'TEST-001')
def test_search_in_geltungsbereich(self):
"""Test search in Geltungsbereich content"""
response = self.client.post('/search/', {'q': 'Geltungsbereich'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Standards mit')
def test_search_no_results(self):
"""Test search with no results"""
response = self.client.post('/search/', {'q': 'NichtVorhanden'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Keine Resultate für "NichtVorhanden"')
def test_search_expired_vorgabe_not_included(self):
"""Test that expired Vorgaben are not included in results"""
# Create expired Vorgabe
expired_vorgabe = Vorgabe.objects.create(
order=2,
nummer=2,
dokument=self.dokument,
thema=self.thema,
titel="Abgelaufene Vorgabe",
gueltigkeit_von=date.today() - timedelta(days=10),
gueltigkeit_bis=date.today() - timedelta(days=1)
)
VorgabeKurztext.objects.create(
abschnitt=expired_vorgabe,
inhalt="Abgelaufener Inhalt mit Test"
)
response = self.client.post('/search/', {'q': 'Test'})
self.assertEqual(response.status_code, 200)
# Should only find the active Vorgabe, not the expired one
self.assertContains(response, 'Test Vorgabe Titel')
# The expired vorgabe should not appear in results
self.assertNotContains(response, 'Abgelaufene Vorgabe')
def test_search_empty_term_validation(self):
"""Test validation for empty search term"""
response = self.client.post('/search/', {'q': ''})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Fehler:')
self.assertContains(response, 'Suchbegriff darf nicht leer sein')
def test_search_no_term_validation(self):
"""Test validation when no search term is provided"""
response = self.client.post('/search/', {})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Fehler:')
self.assertContains(response, 'Suchbegriff darf nicht leer sein')
def test_search_html_tags_stripped(self):
"""Test that HTML tags are stripped from search input"""
response = self.client.post('/search/', {'q': '<script>alert("xss")</script>Test'})
self.assertEqual(response.status_code, 200)
# Should search for "alert('xss')Test" after HTML tag removal
self.assertContains(response, 'Suchresultate für alert(&quot;xss&quot;)Test')
def test_search_invalid_characters_validation(self):
"""Test validation for invalid characters"""
response = self.client.post('/search/', {'q': 'Test| DROP TABLE users'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Fehler:')
self.assertContains(response, 'Ungültige Zeichen im Suchbegriff')
def test_search_too_long_validation(self):
"""Test validation for overly long search terms"""
long_term = 'a' * 201 # 201 characters, exceeds limit of 200
response = self.client.post('/search/', {'q': long_term})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Fehler:')
self.assertContains(response, 'Suchbegriff ist zu lang')
def test_search_max_length_allowed(self):
"""Test that exactly 200 characters are allowed"""
max_term = 'a' * 200 # Exactly 200 characters
response = self.client.post('/search/', {'q': max_term})
self.assertEqual(response.status_code, 200)
# Should not show validation error
self.assertNotContains(response, 'Fehler:')
def test_search_german_umlauts_allowed(self):
"""Test that German umlauts are allowed in search"""
response = self.client.post('/search/', {'q': 'Test Müller äöü ÄÖÜ ß'})
self.assertEqual(response.status_code, 200)
# Should not show validation error
self.assertNotContains(response, 'Fehler:')
def test_search_special_characters_allowed(self):
"""Test that allowed special characters work"""
response = self.client.post('/search/', {'q': 'Test-Test, Test: Test; Test! Test? (Test) [Test] {Test} "Test" \'Test\''})
self.assertEqual(response.status_code, 200)
# Should not show validation error
self.assertNotContains(response, 'Fehler:')
def test_search_input_preserved_on_error(self):
"""Test that search input is preserved on validation errors"""
response = self.client.post('/search/', {'q': '<script>Test</script>'})
self.assertEqual(response.status_code, 200)
# The input should be preserved (escaped) in the form
# Since HTML tags are stripped, we expect "Test" to be searched
self.assertContains(response, 'Suchresultate für Test')
def test_search_xss_prevention_in_results(self):
"""Test that search terms are escaped in results to prevent XSS"""
# Create content with potential XSS
self.kurztext.inhalt = "Content with <script>alert('xss')</script> term"
self.kurztext.save()
response = self.client.post('/search/', {'q': 'term'})
self.assertEqual(response.status_code, 200)
# The script tag should be escaped in the output
# Note: This depends on how the template renders the content
self.assertContains(response, 'Suchresultate für term')
@patch('pages.views.pprint.pp')
def test_search_result_logging(self, mock_pprint):
"""Test that search results are logged for debugging"""
response = self.client.post('/search/', {'q': 'Test'})
self.assertEqual(response.status_code, 200)
# Verify that pprint.pp was called with the result
mock_pprint.assert_called_once()
def test_search_multiple_documents(self):
"""Test search across multiple documents"""
# Create second document
dokument2 = Dokument.objects.create(
nummer="TEST-002",
dokumententyp=self.dokumententyp,
name="Zweites Test Dokument",
gueltigkeit_von=date.today(),
aktiv=True
)
vorgabe2 = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=dokument2,
thema=self.thema,
titel="Zweite Test Vorgabe",
gueltigkeit_von=date.today()
)
VorgabeKurztext.objects.create(
abschnitt=vorgabe2,
inhalt="Zweiter Test Inhalt"
)
response = self.client.post('/search/', {'q': 'Test'})
self.assertEqual(response.status_code, 200)
# Should find results from both documents
self.assertContains(response, 'TEST-001')
self.assertContains(response, 'TEST-002')
class SearchValidationTest(TestCase):
"""Test the validate_search_input function directly"""
def test_validate_search_input_valid(self):
"""Test valid search input"""
from pages.views import validate_search_input
result = validate_search_input("Test Suchbegriff")
self.assertEqual(result, "Test Suchbegriff")
def test_validate_search_input_empty(self):
"""Test empty search input"""
from pages.views import validate_search_input
with self.assertRaises(ValidationError) as context:
validate_search_input("")
self.assertIn("Suchbegriff darf nicht leer sein", str(context.exception))
def test_validate_search_input_html_stripped(self):
"""Test that HTML tags are stripped"""
from pages.views import validate_search_input
result = validate_search_input("<script>alert('xss')</script>Test")
self.assertEqual(result, "alert('xss')Test")
def test_validate_search_input_invalid_chars(self):
"""Test validation of invalid characters"""
from pages.views import validate_search_input
with self.assertRaises(ValidationError) as context:
validate_search_input("Test| DROP TABLE users")
self.assertIn("Ungültige Zeichen im Suchbegriff", str(context.exception))
def test_validate_search_input_too_long(self):
"""Test length validation"""
from pages.views import validate_search_input
with self.assertRaises(ValidationError) as context:
validate_search_input("a" * 201)
self.assertIn("Suchbegriff ist zu lang", str(context.exception))
def test_validate_search_input_whitespace_stripped(self):
"""Test that whitespace is stripped"""
from pages.views import validate_search_input
result = validate_search_input(" Test Suchbegriff ")
self.assertEqual(result, "Test Suchbegriff")

View File

@@ -1,31 +1,71 @@
from django.shortcuts import render
from django.core.exceptions import ValidationError
from django.utils.html import escape
import re
from abschnitte.utils import render_textabschnitte
from dokumente.models import Dokument, VorgabeLangtext, VorgabeKurztext, Geltungsbereich
from dokumente.models import Dokument, VorgabeLangtext, VorgabeKurztext, Geltungsbereich, Vorgabe
from itertools import groupby
import datetime
import pprint
def startseite(request):
standards=list(Dokument.objects.all())
standards=list(Dokument.objects.filter(aktiv=True))
return render(request, 'startseite.html', {"dokumente":standards,})
def validate_search_input(search_term):
"""
Validate search input to prevent SQL injection and XSS
"""
if not search_term:
raise ValidationError("Suchbegriff darf nicht leer sein")
# Remove any HTML tags to prevent XSS
search_term = re.sub(r'<[^>]*>', '', search_term)
# Allow only alphanumeric characters, spaces, and basic punctuation
# This prevents SQL injection and other malicious input while allowing useful characters
if not re.match(r'^[a-zA-Z0-9äöüÄÖÜß\s\-\.\,\:\;\!\?\(\)\[\]\{\}\"\']+$', search_term):
raise ValidationError("Ungültige Zeichen im Suchbegriff")
# Limit length to prevent DoS attacks
if len(search_term) > 200:
raise ValidationError("Suchbegriff ist zu lang")
return search_term.strip()
def search(request):
if request.method == "GET":
return render(request, 'search.html')
elif request.method == "POST":
suchbegriff=request.POST.get("q")
raw_search_term = request.POST.get("q", "")
try:
suchbegriff = validate_search_input(raw_search_term)
except ValidationError as e:
return render(request, 'search.html', {
'error_message': str(e),
'search_term': escape(raw_search_term)
})
# Escape the search term for display in templates
safe_search_term = escape(suchbegriff)
result= {"all": {}}
qs = VorgabeKurztext.objects.filter(inhalt__contains=suchbegriff).exclude(abschnitt__gueltigkeit_bis__lt=datetime.date.today())
qs = VorgabeKurztext.objects.filter(inhalt__icontains=suchbegriff).exclude(abschnitt__gueltigkeit_bis__lt=datetime.date.today())
result["kurztext"] = {k: [o.abschnitt for o in g] for k, g in groupby(qs, key=lambda o: o.abschnitt.dokument)}
qs = VorgabeLangtext.objects.filter(inhalt__contains=suchbegriff).exclude(abschnitt__gueltigkeit_bis__lt=datetime.date.today())
qs = VorgabeLangtext.objects.filter(inhalt__icontains=suchbegriff).exclude(abschnitt__gueltigkeit_bis__lt=datetime.date.today())
result['langtext']= {k: [o.abschnitt for o in g] for k, g in groupby(qs, key=lambda o: o.abschnitt.dokument)}
qs = Vorgabe.objects.filter(titel__icontains=suchbegriff).exclude(gueltigkeit_bis__lt=datetime.date.today())
result['titel']= {k: list(g) for k, g in groupby(qs, key=lambda o: o.dokument)}
for r in result.keys():
for s in result[r].keys():
result["all"][s] = set(result[r][s])
if r == 'titel':
result["all"][s] = set(result["all"].get(s, set()) | set(result[r][s]))
else:
result["all"][s] = set(result["all"].get(s, set()) | set(result[r][s]))
result["geltungsbereich"]={}
geltungsbereich=set(list([x.geltungsbereich for x in Geltungsbereich.objects.filter(inhalt__contains=suchbegriff)]))
geltungsbereich=set(list([x.geltungsbereich for x in Geltungsbereich.objects.filter(inhalt__icontains=suchbegriff)]))
for s in geltungsbereich:
result["geltungsbereich"][s]=render_textabschnitte(s.geltungsbereich_set.order_by("order"))
pprint.pp (result)
return render(request,"results.html",{"suchbegriff":suchbegriff,"resultat":result})
return render(request,"results.html",{"suchbegriff":safe_search_term,"resultat":result})

View File

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

View File

@@ -1,3 +1,398 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from .models import Referenz, Referenzerklaerung
from abschnitte.models import AbschnittTyp
# Create your tests here.
class ReferenzModelTest(TestCase):
"""Test cases for Referenz model"""
def setUp(self):
"""Set up test data"""
self.referenz = Referenz.objects.create(
name_nummer="ISO-27001",
name_text="Information Security Management",
url="https://www.iso.org/isoiec-27001-information-security.html"
)
def test_referenz_creation(self):
"""Test that Referenz is created correctly"""
self.assertEqual(self.referenz.name_nummer, "ISO-27001")
self.assertEqual(self.referenz.name_text, "Information Security Management")
self.assertEqual(self.referenz.url, "https://www.iso.org/isoiec-27001-information-security.html")
self.assertIsNone(self.referenz.oberreferenz)
def test_referenz_str(self):
"""Test string representation of Referenz"""
self.assertEqual(str(self.referenz), "ISO-27001")
def test_referenz_verbose_name_plural(self):
"""Test verbose name plural"""
self.assertEqual(
Referenz._meta.verbose_name_plural,
"Referenzen"
)
def test_referenz_path_method(self):
"""Test Path method for root reference"""
path = self.referenz.Path()
self.assertEqual(path, "ISO-27001 (Information Security Management)")
def test_referenz_path_without_name_text(self):
"""Test Path method when name_text is empty"""
referenz_no_text = Referenz.objects.create(
name_nummer="NIST-800-53"
)
path = referenz_no_text.Path()
self.assertEqual(path, "NIST-800-53")
def test_referenz_blank_fields(self):
"""Test that optional fields can be blank"""
referenz_minimal = Referenz.objects.create(
name_nummer="TEST-001"
)
self.assertEqual(referenz_minimal.name_text, "")
self.assertEqual(referenz_minimal.url, "")
self.assertIsNone(referenz_minimal.oberreferenz)
def test_referenz_max_lengths(self):
"""Test max_length constraints"""
max_name_nummer = "a" * 100
max_name_text = "b" * 255
referenz = Referenz.objects.create(
name_nummer=max_name_nummer,
name_text=max_name_text
)
self.assertEqual(referenz.name_nummer, max_name_nummer)
self.assertEqual(referenz.name_text, max_name_text)
def test_create_multiple_references(self):
"""Test creating multiple Referenz objects"""
references = [
("ISO-9001", "Quality Management"),
("ISO-14001", "Environmental Management"),
("ISO-45001", "Occupational Health and Safety")
]
for name_nummer, name_text in references:
Referenz.objects.create(
name_nummer=name_nummer,
name_text=name_text
)
self.assertEqual(Referenz.objects.count(), 4) # Including setUp referenz
class ReferenzHierarchyTest(TestCase):
"""Test cases for Referenz hierarchy using MPTT"""
def setUp(self):
"""Set up hierarchical test data"""
# Create root references
self.iso_root = Referenz.objects.create(
name_nummer="ISO",
name_text="International Organization for Standardization"
)
self.iso_27000_series = Referenz.objects.create(
name_nummer="ISO-27000",
name_text="Information Security Management System Family",
oberreferenz=self.iso_root
)
self.iso_27001 = Referenz.objects.create(
name_nummer="ISO-27001",
name_text="Information Security Management",
oberreferenz=self.iso_27000_series
)
self.iso_27002 = Referenz.objects.create(
name_nummer="ISO-27002",
name_text="Code of Practice for Information Security Controls",
oberreferenz=self.iso_27000_series
)
def test_hierarchy_relationships(self):
"""Test parent-child relationships"""
self.assertEqual(self.iso_27000_series.oberreferenz, self.iso_root)
self.assertEqual(self.iso_27001.oberreferenz, self.iso_27000_series)
self.assertEqual(self.iso_27002.oberreferenz, self.iso_27000_series)
def test_get_ancestors(self):
"""Test getting ancestors"""
ancestors = self.iso_27001.get_ancestors()
expected_ancestors = [self.iso_root, self.iso_27000_series]
self.assertEqual(list(ancestors), expected_ancestors)
def test_get_ancestors_include_self(self):
"""Test getting ancestors including self"""
ancestors = self.iso_27001.get_ancestors(include_self=True)
expected_ancestors = [self.iso_root, self.iso_27000_series, self.iso_27001]
self.assertEqual(list(ancestors), expected_ancestors)
def test_get_descendants(self):
"""Test getting descendants"""
descendants = self.iso_27000_series.get_descendants()
expected_descendants = [self.iso_27001, self.iso_27002]
self.assertEqual(list(descendants), expected_descendants)
def test_get_children(self):
"""Test getting direct children"""
children = self.iso_27000_series.get_children()
expected_children = [self.iso_27001, self.iso_27002]
self.assertEqual(list(children), expected_children)
def test_get_root(self):
"""Test getting root of hierarchy"""
root = self.iso_27001.get_root()
self.assertEqual(root, self.iso_root)
def test_is_root(self):
"""Test is_root method"""
self.assertTrue(self.iso_root.is_root_node())
self.assertFalse(self.iso_27001.is_root_node())
def test_is_leaf(self):
"""Test is_leaf method"""
self.assertFalse(self.iso_root.is_leaf_node())
self.assertFalse(self.iso_27000_series.is_leaf_node())
self.assertTrue(self.iso_27001.is_leaf_node())
self.assertTrue(self.iso_27002.is_leaf_node())
def test_level_property(self):
"""Test level property"""
self.assertEqual(self.iso_root.level, 0)
self.assertEqual(self.iso_27000_series.level, 1)
self.assertEqual(self.iso_27001.level, 2)
self.assertEqual(self.iso_27002.level, 2)
def test_path_method_with_hierarchy(self):
"""Test Path method with hierarchical references"""
path = self.iso_27001.Path()
expected_path = "ISO → ISO-27000 → ISO-27001 (Information Security Management)"
self.assertEqual(path, expected_path)
def test_path_method_without_name_text_in_hierarchy(self):
"""Test Path method when intermediate nodes have no name_text"""
# Create reference without name_text
ref_no_text = Referenz.objects.create(
name_nummer="NO-TEXT",
oberreferenz=self.iso_root
)
child_ref = Referenz.objects.create(
name_nummer="CHILD",
name_text="Child Reference",
oberreferenz=ref_no_text
)
path = child_ref.Path()
expected_path = "ISO → NO-TEXT → CHILD (Child Reference)"
self.assertEqual(path, expected_path)
def test_order_insertion_by(self):
"""Test that references are ordered by name_nummer"""
# Create more children in different order
ref_c = Referenz.objects.create(
name_nummer="C-REF",
oberreferenz=self.iso_root
)
ref_a = Referenz.objects.create(
name_nummer="A-REF",
oberreferenz=self.iso_root
)
ref_b = Referenz.objects.create(
name_nummer="B-REF",
oberreferenz=self.iso_root
)
children = list(self.iso_root.get_children())
# Should be ordered alphabetically by name_nummer
expected_order = [ref_a, ref_b, ref_c, self.iso_27000_series]
self.assertEqual(children, expected_order)
class ReferenzerklaerungModelTest(TestCase):
"""Test cases for Referenzerklaerung model"""
def setUp(self):
"""Set up test data"""
self.referenz = Referenz.objects.create(
name_nummer="ISO-27001",
name_text="Information Security Management"
)
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="text"
)
self.erklaerung = Referenzerklaerung.objects.create(
erklaerung=self.referenz,
abschnitttyp=self.abschnitttyp,
inhalt="Dies ist eine Erklärung für ISO-27001.",
order=1
)
def test_referenzerklaerung_creation(self):
"""Test that Referenzerklaerung is created correctly"""
self.assertEqual(self.erklaerung.erklaerung, self.referenz)
self.assertEqual(self.erklaerung.abschnitttyp, self.abschnitttyp)
self.assertEqual(self.erklaerung.inhalt, "Dies ist eine Erklärung für ISO-27001.")
self.assertEqual(self.erklaerung.order, 1)
def test_referenzerklaerung_foreign_key_relationship(self):
"""Test foreign key relationship to Referenz"""
self.assertEqual(self.erklaerung.erklaerung.name_nummer, "ISO-27001")
self.assertEqual(self.erklaerung.erklaerung.name_text, "Information Security Management")
def test_referenzerklaerung_cascade_delete(self):
"""Test that deleting Referenz cascades to Referenzerklaerung"""
referenz_count = Referenz.objects.count()
erklaerung_count = Referenzerklaerung.objects.count()
self.referenz.delete()
self.assertEqual(Referenz.objects.count(), referenz_count - 1)
self.assertEqual(Referenzerklaerung.objects.count(), erklaerung_count - 1)
def test_referenzerklaerung_verbose_name(self):
"""Test verbose name"""
self.assertEqual(
Referenzerklaerung._meta.verbose_name,
"Erklärung"
)
def test_referenzerklaerung_multiple_explanations(self):
"""Test creating multiple explanations for one Referenz"""
abschnitttyp2 = AbschnittTyp.objects.create(abschnitttyp="liste ungeordnet")
erklaerung2 = Referenzerklaerung.objects.create(
erklaerung=self.referenz,
abschnitttyp=abschnitttyp2,
inhalt="Zweite Erklärung für ISO-27001.",
order=2
)
explanations = Referenzerklaerung.objects.filter(erklaerung=self.referenz)
self.assertEqual(explanations.count(), 2)
self.assertIn(self.erklaerung, explanations)
self.assertIn(erklaerung2, explanations)
def test_referenzerklaerung_ordering(self):
"""Test that explanations can be ordered"""
erklaerung2 = Referenzerklaerung.objects.create(
erklaerung=self.referenz,
abschnitttyp=self.abschnitttyp,
inhalt="Zweite Erklärung",
order=3
)
erklaerung3 = Referenzerklaerung.objects.create(
erklaerung=self.referenz,
abschnitttyp=self.abschnitttyp,
inhalt="Erste Erklärung",
order=2
)
ordered = Referenzerklaerung.objects.filter(erklaerung=self.referenz).order_by('order')
expected_order = [self.erklaerung, erklaerung3, erklaerung2]
self.assertEqual(list(ordered), expected_order)
def test_referenzerklaerung_blank_fields(self):
"""Test that optional fields can be blank/null"""
referenz2 = Referenz.objects.create(name_nummer="TEST-001")
erklaerung_blank = Referenzerklaerung.objects.create(
erklaerung=referenz2
)
self.assertIsNone(erklaerung_blank.abschnitttyp)
self.assertIsNone(erklaerung_blank.inhalt)
self.assertEqual(erklaerung_blank.order, 0)
def test_referenzerklaerung_inheritance(self):
"""Test that Referenzerklaerung inherits from Textabschnitt"""
# Check that it has the expected fields from Textabschnitt
self.assertTrue(hasattr(self.erklaerung, 'abschnitttyp'))
self.assertTrue(hasattr(self.erklaerung, 'inhalt'))
self.assertTrue(hasattr(self.erklaerung, 'order'))
# Check that the fields work as expected
self.assertIsInstance(self.erklaerung.abschnitttyp, AbschnittTyp)
self.assertIsInstance(self.erklaerung.inhalt, str)
self.assertIsInstance(self.erklaerung.order, int)
class ReferenzIntegrationTest(TestCase):
"""Integration tests for Referenz app"""
def setUp(self):
"""Set up test data"""
self.root_ref = Referenz.objects.create(
name_nummer="ROOT",
name_text="Root Reference"
)
self.child_ref = Referenz.objects.create(
name_nummer="CHILD",
name_text="Child Reference",
oberreferenz=self.root_ref
)
self.abschnitttyp = AbschnittTyp.objects.create(abschnitttyp="text")
self.erklaerung = Referenzerklaerung.objects.create(
erklaerung=self.child_ref,
abschnitttyp=self.abschnitttyp,
inhalt="Explanation for child reference",
order=1
)
def test_reference_with_explanations_query(self):
"""Test querying references with their explanations"""
references_with_explanations = Referenz.objects.filter(
referenzerklaerung__isnull=False
).distinct()
self.assertEqual(references_with_explanations.count(), 1)
self.assertIn(self.child_ref, references_with_explanations)
self.assertNotIn(self.root_ref, references_with_explanations)
def test_reference_without_explanations(self):
"""Test finding references without explanations"""
references_without_explanations = Referenz.objects.filter(
referenzerklaerung__isnull=True
)
self.assertEqual(references_without_explanations.count(), 1)
self.assertEqual(references_without_explanations.first(), self.root_ref)
def test_explanation_count_annotation(self):
"""Test annotating references with explanation count"""
from django.db.models import Count
references_with_count = Referenz.objects.annotate(
explanation_count=Count('referenzerklaerung')
)
for reference in references_with_count:
if reference == self.child_ref:
self.assertEqual(reference.explanation_count, 1)
else:
self.assertEqual(reference.explanation_count, 0)
def test_hierarchy_with_explanations(self):
"""Test that explanations work correctly with hierarchical references"""
# Add explanation to root reference
root_erklaerung = Referenzerklaerung.objects.create(
erklaerung=self.root_ref,
abschnitttyp=self.abschnitttyp,
inhalt="Explanation for root reference",
order=1
)
# Both references should now have explanations
references_with_explanations = Referenz.objects.filter(
referenzerklaerung__isnull=False
).distinct()
self.assertEqual(references_with_explanations.count(), 2)
self.assertIn(self.root_ref, references_with_explanations)
self.assertIn(self.child_ref, references_with_explanations)

View File

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

View File

@@ -1,3 +1,367 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db.models import Count
from .models import Rolle, RollenBeschreibung
from abschnitte.models import AbschnittTyp
# Create your tests here.
class RolleModelTest(TestCase):
"""Test cases for Rolle model"""
def setUp(self):
"""Set up test data"""
self.rolle = Rolle.objects.create(
name="Systemadministrator"
)
def test_rolle_creation(self):
"""Test that Rolle is created correctly"""
self.assertEqual(self.rolle.name, "Systemadministrator")
def test_rolle_str(self):
"""Test string representation of Rolle"""
self.assertEqual(str(self.rolle), "Systemadministrator")
def test_rolle_primary_key(self):
"""Test that name field is the primary key"""
pk_field = Rolle._meta.pk
self.assertEqual(pk_field.name, 'name')
self.assertEqual(pk_field.max_length, 100)
def test_rolle_verbose_name_plural(self):
"""Test verbose name plural"""
self.assertEqual(
Rolle._meta.verbose_name_plural,
"Rollen"
)
def test_rolle_max_length(self):
"""Test max_length constraint"""
max_length_rolle = "a" * 100
rolle = Rolle.objects.create(name=max_length_rolle)
self.assertEqual(rolle.name, max_length_rolle)
def test_rolle_unique(self):
"""Test that name must be unique"""
with self.assertRaises(Exception):
Rolle.objects.create(name="Systemadministrator")
def test_create_multiple_rollen(self):
"""Test creating multiple Rolle objects"""
rollen = [
"Datenschutzbeauftragter",
"IT-Sicherheitsbeauftragter",
"Risikomanager",
"Compliance-Officer"
]
for rolle_name in rollen:
Rolle.objects.create(name=rolle_name)
self.assertEqual(Rolle.objects.count(), 5) # Including setUp rolle
def test_rolle_case_sensitivity(self):
"""Test that role name is case sensitive"""
rolle_lower = Rolle.objects.create(name="systemadministrator")
self.assertNotEqual(self.rolle.pk, rolle_lower.pk)
self.assertEqual(Rolle.objects.count(), 2)
def test_rolle_with_special_characters(self):
"""Test creating roles with special characters"""
special_roles = [
"IT-Administrator",
"CISO (Chief Information Security Officer)",
"Datenschutz-Beauftragter/-in",
"Sicherheitsbeauftragter"
]
for role_name in special_roles:
rolle = Rolle.objects.create(name=role_name)
self.assertEqual(rolle.name, role_name)
self.assertEqual(Rolle.objects.count(), 5) # Including setUp rolle
class RollenBeschreibungModelTest(TestCase):
"""Test cases for RollenBeschreibung model"""
def setUp(self):
"""Set up test data"""
self.rolle = Rolle.objects.create(
name="Systemadministrator"
)
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="text"
)
self.beschreibung = RollenBeschreibung.objects.create(
abschnitt=self.rolle,
abschnitttyp=self.abschnitttyp,
inhalt="Der Systemadministrator ist für die Verwaltung und Wartung der IT-Systeme verantwortlich.",
order=1
)
def test_rollenbeschreibung_creation(self):
"""Test that RollenBeschreibung is created correctly"""
self.assertEqual(self.beschreibung.abschnitt, self.rolle)
self.assertEqual(self.beschreibung.abschnitttyp, self.abschnitttyp)
self.assertEqual(self.beschreibung.inhalt, "Der Systemadministrator ist für die Verwaltung und Wartung der IT-Systeme verantwortlich.")
self.assertEqual(self.beschreibung.order, 1)
def test_rollenbeschreibung_foreign_key_relationship(self):
"""Test foreign key relationship to Rolle"""
self.assertEqual(self.beschreibung.abschnitt.name, "Systemadministrator")
def test_rollenbeschreibung_cascade_delete(self):
"""Test that deleting Rolle cascades to RollenBeschreibung"""
rolle_count = Rolle.objects.count()
beschreibung_count = RollenBeschreibung.objects.count()
self.rolle.delete()
self.assertEqual(Rolle.objects.count(), rolle_count - 1)
self.assertEqual(RollenBeschreibung.objects.count(), beschreibung_count - 1)
def test_rollenbeschreibung_verbose_names(self):
"""Test verbose names"""
self.assertEqual(
RollenBeschreibung._meta.verbose_name,
"Rollenbeschreibungs-Abschnitt"
)
self.assertEqual(
RollenBeschreibung._meta.verbose_name_plural,
"Rollenbeschreibung"
)
def test_rollenbeschreibung_multiple_descriptions(self):
"""Test creating multiple descriptions for one Rolle"""
abschnitttyp2 = AbschnittTyp.objects.create(abschnitttyp="liste ungeordnet")
beschreibung2 = RollenBeschreibung.objects.create(
abschnitt=self.rolle,
abschnitttyp=abschnitttyp2,
inhalt="Aufgaben:\n- Systemüberwachung\n- Backup-Management\n- Benutzeradministration",
order=2
)
descriptions = RollenBeschreibung.objects.filter(abschnitt=self.rolle)
self.assertEqual(descriptions.count(), 2)
self.assertIn(self.beschreibung, descriptions)
self.assertIn(beschreibung2, descriptions)
def test_rollenbeschreibung_ordering(self):
"""Test that descriptions can be ordered"""
beschreibung2 = RollenBeschreibung.objects.create(
abschnitt=self.rolle,
abschnitttyp=self.abschnitttyp,
inhalt="Zweite Beschreibung",
order=3
)
beschreibung3 = RollenBeschreibung.objects.create(
abschnitt=self.rolle,
abschnitttyp=self.abschnitttyp,
inhalt="Erste Beschreibung",
order=2
)
ordered = RollenBeschreibung.objects.filter(abschnitt=self.rolle).order_by('order')
expected_order = [self.beschreibung, beschreibung3, beschreibung2]
self.assertEqual(list(ordered), expected_order)
def test_rollenbeschreibung_blank_fields(self):
"""Test that optional fields can be blank/null"""
rolle2 = Rolle.objects.create(name="Testrolle")
beschreibung_blank = RollenBeschreibung.objects.create(
abschnitt=rolle2
)
self.assertIsNone(beschreibung_blank.abschnitttyp)
self.assertIsNone(beschreibung_blank.inhalt)
self.assertEqual(beschreibung_blank.order, 0)
def test_rollenbeschreibung_inheritance(self):
"""Test that RollenBeschreibung inherits from Textabschnitt"""
# Check that it has the expected fields from Textabschnitt
self.assertTrue(hasattr(self.beschreibung, 'abschnitttyp'))
self.assertTrue(hasattr(self.beschreibung, 'inhalt'))
self.assertTrue(hasattr(self.beschreibung, 'order'))
# Check that the fields work as expected
self.assertIsInstance(self.beschreibung.abschnitttyp, AbschnittTyp)
self.assertIsInstance(self.beschreibung.inhalt, str)
self.assertIsInstance(self.beschreibung.order, int)
def test_rollenbeschreibung_different_types(self):
"""Test creating descriptions with different section types"""
# Create different section types
typ_list = AbschnittTyp.objects.create(abschnitttyp="liste ungeordnet")
typ_table = AbschnittTyp.objects.create(abschnitttyp="tabelle")
# Create descriptions with different types
beschreibung_text = RollenBeschreibung.objects.create(
abschnitt=self.rolle,
abschnitttyp=self.abschnitttyp,
inhalt="Textbeschreibung der Rolle",
order=1
)
beschreibung_list = RollenBeschreibung.objects.create(
abschnitt=self.rolle,
abschnitttyp=typ_list,
inhalt="Aufgabe 1\nAufgabe 2\nAufgabe 3",
order=2
)
beschreibung_table = RollenBeschreibung.objects.create(
abschnitt=self.rolle,
abschnitttyp=typ_table,
inhalt="| Verantwortung | Priorität |\n|--------------|------------|\n| Systemwartung | Hoch |",
order=3
)
# Verify all descriptions are created
descriptions = RollenBeschreibung.objects.filter(abschnitt=self.rolle)
self.assertEqual(descriptions.count(), 4) # Including setUp beschreibung
# Verify types are correct
self.assertEqual(beschreibung_text.abschnitttyp, self.abschnitttyp)
self.assertEqual(beschreibung_list.abschnitttyp, typ_list)
self.assertEqual(beschreibung_table.abschnitttyp, typ_table)
class RolleIntegrationTest(TestCase):
"""Integration tests for Rolle app"""
def setUp(self):
"""Set up test data"""
self.rolle1 = Rolle.objects.create(name="IT-Sicherheitsbeauftragter")
self.rolle2 = Rolle.objects.create(name="Datenschutzbeauftragter")
self.abschnitttyp = AbschnittTyp.objects.create(abschnitttyp="text")
self.beschreibung1 = RollenBeschreibung.objects.create(
abschnitt=self.rolle1,
abschnitttyp=self.abschnitttyp,
inhalt="Beschreibung für IT-Sicherheitsbeauftragten",
order=1
)
self.beschreibung2 = RollenBeschreibung.objects.create(
abschnitt=self.rolle2,
abschnitttyp=self.abschnitttyp,
inhalt="Beschreibung für Datenschutzbeauftragten",
order=1
)
def test_rolle_with_descriptions_query(self):
"""Test querying Rollen with their descriptions"""
rollen_with_descriptions = Rolle.objects.filter(
rollenbeschreibung__isnull=False
).distinct()
self.assertEqual(rollen_with_descriptions.count(), 2)
self.assertIn(self.rolle1, rollen_with_descriptions)
self.assertIn(self.rolle2, rollen_with_descriptions)
def test_rolle_without_descriptions(self):
"""Test finding Rollen without descriptions"""
rolle3 = Rolle.objects.create(name="Compliance-Officer")
rollen_without_descriptions = Rolle.objects.filter(
rollenbeschreibung__isnull=True
)
self.assertEqual(rollen_without_descriptions.count(), 1)
self.assertEqual(rollen_without_descriptions.first(), rolle3)
def test_description_count_annotation(self):
"""Test annotating Rollen with description count"""
from django.db.models import Count
rollen_with_count = Rolle.objects.annotate(
description_count=Count('rollenbeschreibung')
)
for rolle in rollen_with_count:
if rolle.name in ["IT-Sicherheitsbeauftragter", "Datenschutzbeauftragter"]:
self.assertEqual(rolle.description_count, 1)
else:
self.assertEqual(rolle.description_count, 0)
def test_multiple_descriptions_per_rolle(self):
"""Test multiple descriptions for a single role"""
# Add more descriptions to rolle1
abschnitttyp2 = AbschnittTyp.objects.create(abschnitttyp="liste ungeordnet")
beschreibung2 = RollenBeschreibung.objects.create(
abschnitt=self.rolle1,
abschnitttyp=abschnitttyp2,
inhalt="Zusätzliche Aufgaben:\n- Überwachung\n- Berichterstattung",
order=2
)
# Check that rolle1 now has 2 descriptions
descriptions = RollenBeschreibung.objects.filter(abschnitt=self.rolle1)
self.assertEqual(descriptions.count(), 2)
# Check annotation
rolle_with_count = Rolle.objects.annotate(
description_count=Count('rollenbeschreibung')
).get(pk=self.rolle1.pk)
self.assertEqual(rolle_with_count.description_count, 2)
def test_role_descriptions_ordered(self):
"""Test that role descriptions are returned in correct order"""
# Add more descriptions in random order
beschreibung2 = RollenBeschreibung.objects.create(
abschnitt=self.rolle1,
abschnitttyp=self.abschnitttyp,
inhalt="Dritte Beschreibung",
order=3
)
beschreibung3 = RollenBeschreibung.objects.create(
abschnitt=self.rolle1,
abschnitttyp=self.abschnitttyp,
inhalt="Zweite Beschreibung",
order=2
)
# Get descriptions in order
ordered_descriptions = RollenBeschreibung.objects.filter(
abschnitt=self.rolle1
).order_by('order')
expected_order = [self.beschreibung1, beschreibung3, beschreibung2]
self.assertEqual(list(ordered_descriptions), expected_order)
def test_role_search_by_name(self):
"""Test searching roles by name"""
# Test exact match
exact_match = Rolle.objects.filter(name="IT-Sicherheitsbeauftragter")
self.assertEqual(exact_match.count(), 1)
self.assertEqual(exact_match.first(), self.rolle1)
# Test case-sensitive contains
contains_match = Rolle.objects.filter(name__contains="Sicherheits")
self.assertEqual(contains_match.count(), 1)
self.assertEqual(contains_match.first(), self.rolle1)
# Test case-insensitive contains
icontains_match = Rolle.objects.filter(name__icontains="sicherheits")
self.assertEqual(icontains_match.count(), 1)
self.assertEqual(icontains_match.first(), self.rolle1)
def test_role_with_long_descriptions(self):
"""Test roles with long description content"""
long_content = "Dies ist eine sehr lange Beschreibung " * 50 # Repeat to make it long
rolle_long = Rolle.objects.create(name="Rolle mit langer Beschreibung")
beschreibung_long = RollenBeschreibung.objects.create(
abschnitt=rolle_long,
abschnitttyp=self.abschnitttyp,
inhalt=long_content,
order=1
)
# Verify the long content is stored correctly
retrieved = RollenBeschreibung.objects.get(pk=beschreibung_long.pk)
self.assertEqual(retrieved.inhalt, long_content)
self.assertGreater(len(retrieved.inhalt), 1000) # Should be quite long

View File

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

View File

@@ -1,21 +1,58 @@
window.addEventListener('load', function () {
setTimeout(() => {
const vorgabenBlocks = document.querySelectorAll('.djn-dynamic-form-Standards-vorgabe');
console.log("Found", vorgabenBlocks.length, "Vorgaben blocks");
// Try different selectors for nested admin vorgabe elements
const selectors = [
'.djn-dynamic-form-dokumente-vorgabe',
'.djn-dynamic-form-Standards-vorgabe',
'.inline-related[data-inline-type="stacked"]',
'.nested-inline'
];
let vorgabenBlocks = [];
for (const selector of selectors) {
vorgabenBlocks = document.querySelectorAll(selector);
if (vorgabenBlocks.length > 0) {
console.log("Found", vorgabenBlocks.length, "Vorgaben blocks with selector:", selector);
break;
}
}
if (vorgabenBlocks.length === 0) {
console.log("No Vorgaben blocks found, trying fallback...");
// Fallback: look for any inline with vorgabe in the class
vorgabenBlocks = document.querySelectorAll('[class*="vorgabe"]');
}
vorgabenBlocks.forEach((block, index) => {
const header = document.createElement('div');
header.className = 'vorgabe-toggle-header';
header.innerHTML = `▼ Vorgabe ${index + 1}`;
header.style.cursor = 'pointer';
block.parentNode.insertBefore(header, block);
header.addEventListener('click', () => {
const isHidden = block.style.display === 'none';
block.style.display = isHidden ? '' : 'none';
header.innerHTML = `${isHidden ? '▼' : '▶'} Vorgabe ${index + 1}`;
});
// Find the existing title/header within the vorgabe block
const existingHeader = block.querySelector('h3, .inline-label, .module h2, .djn-inline-header');
if (existingHeader) {
// Make the existing header clickable for collapse/expand
existingHeader.style.cursor = 'pointer';
existingHeader.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// 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
});

View File

@@ -1,3 +1,225 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import models
from .models import Stichwort, Stichworterklaerung
from abschnitte.models import AbschnittTyp
# Create your tests here.
class StichwortModelTest(TestCase):
"""Test cases for Stichwort model"""
def setUp(self):
"""Set up test data"""
self.stichwort = Stichwort.objects.create(
stichwort="Sicherheit"
)
def test_stichwort_creation(self):
"""Test that Stichwort is created correctly"""
self.assertEqual(self.stichwort.stichwort, "Sicherheit")
def test_stichwort_str(self):
"""Test string representation of Stichwort"""
self.assertEqual(str(self.stichwort), "Sicherheit")
def test_stichwort_primary_key(self):
"""Test that stichwort field is the primary key"""
pk_field = Stichwort._meta.pk
self.assertEqual(pk_field.name, 'stichwort')
self.assertEqual(pk_field.max_length, 50)
def test_stichwort_verbose_name_plural(self):
"""Test verbose name plural"""
self.assertEqual(
Stichwort._meta.verbose_name_plural,
"Stichworte"
)
def test_stichwort_max_length(self):
"""Test max_length constraint"""
max_length_stichwort = "a" * 50
stichwort = Stichwort.objects.create(stichwort=max_length_stichwort)
self.assertEqual(stichwort.stichwort, max_length_stichwort)
def test_stichwort_unique(self):
"""Test that stichwort must be unique"""
with self.assertRaises(Exception):
Stichwort.objects.create(stichwort="Sicherheit")
def test_create_multiple_stichworte(self):
"""Test creating multiple Stichwort objects"""
stichworte = ['Datenschutz', 'Netzwerk', 'Backup', 'Verschlüsselung']
for stichwort in stichworte:
Stichwort.objects.create(stichwort=stichwort)
self.assertEqual(Stichwort.objects.count(), 5) # Including setUp stichwort
def test_stichwort_case_sensitivity(self):
"""Test that stichwort is case sensitive"""
stichwort_lower = Stichwort.objects.create(stichwort="sicherheit")
self.assertNotEqual(self.stichwort.pk, stichwort_lower.pk)
self.assertEqual(Stichwort.objects.count(), 2)
class StichworterklaerungModelTest(TestCase):
"""Test cases for Stichworterklaerung model"""
def setUp(self):
"""Set up test data"""
self.stichwort = Stichwort.objects.create(
stichwort="Sicherheit"
)
self.abschnitttyp = AbschnittTyp.objects.create(
abschnitttyp="text"
)
self.erklaerung = Stichworterklaerung.objects.create(
erklaerung=self.stichwort,
abschnitttyp=self.abschnitttyp,
inhalt="Dies ist eine Erklärung für Sicherheit.",
order=1
)
def test_stichworterklaerung_creation(self):
"""Test that Stichworterklaerung is created correctly"""
self.assertEqual(self.erklaerung.erklaerung, self.stichwort)
self.assertEqual(self.erklaerung.abschnitttyp, self.abschnitttyp)
self.assertEqual(self.erklaerung.inhalt, "Dies ist eine Erklärung für Sicherheit.")
self.assertEqual(self.erklaerung.order, 1)
def test_stichworterklaerung_foreign_key_relationship(self):
"""Test foreign key relationship to Stichwort"""
self.assertEqual(self.erklaerung.erklaerung.stichwort, "Sicherheit")
def test_stichworterklaerung_cascade_delete(self):
"""Test that deleting Stichwort cascades to Stichworterklaerung"""
stichwort_count = Stichwort.objects.count()
erklaerung_count = Stichworterklaerung.objects.count()
self.stichwort.delete()
self.assertEqual(Stichwort.objects.count(), stichwort_count - 1)
self.assertEqual(Stichworterklaerung.objects.count(), erklaerung_count - 1)
def test_stichworterklaerung_verbose_name(self):
"""Test verbose name"""
self.assertEqual(
Stichworterklaerung._meta.verbose_name,
"Erklärung"
)
def test_stichworterklaerung_multiple_explanations(self):
"""Test creating multiple explanations for one Stichwort"""
abschnitttyp2 = AbschnittTyp.objects.create(abschnitttyp="liste ungeordnet")
erklaerung2 = Stichworterklaerung.objects.create(
erklaerung=self.stichwort,
abschnitttyp=abschnitttyp2,
inhalt="Zweite Erklärung für Sicherheit.",
order=2
)
explanations = Stichworterklaerung.objects.filter(erklaerung=self.stichwort)
self.assertEqual(explanations.count(), 2)
self.assertIn(self.erklaerung, explanations)
self.assertIn(erklaerung2, explanations)
def test_stichworterklaerung_ordering(self):
"""Test that explanations can be ordered"""
erklaerung2 = Stichworterklaerung.objects.create(
erklaerung=self.stichwort,
abschnitttyp=self.abschnitttyp,
inhalt="Zweite Erklärung",
order=3
)
erklaerung3 = Stichworterklaerung.objects.create(
erklaerung=self.stichwort,
abschnitttyp=self.abschnitttyp,
inhalt="Erste Erklärung",
order=2
)
ordered = Stichworterklaerung.objects.filter(erklaerung=self.stichwort).order_by('order')
expected_order = [self.erklaerung, erklaerung3, erklaerung2]
self.assertEqual(list(ordered), expected_order)
def test_stichworterklaerung_blank_fields(self):
"""Test that optional fields can be blank/null"""
stichwort2 = Stichwort.objects.create(stichwort="Test")
erklaerung_blank = Stichworterklaerung.objects.create(
erklaerung=stichwort2
)
self.assertIsNone(erklaerung_blank.abschnitttyp)
self.assertIsNone(erklaerung_blank.inhalt)
self.assertEqual(erklaerung_blank.order, 0)
def test_stichworterklaerung_inheritance(self):
"""Test that Stichworterklaerung inherits from Textabschnitt"""
# Check that it has the expected fields from Textabschnitt
self.assertTrue(hasattr(self.erklaerung, 'abschnitttyp'))
self.assertTrue(hasattr(self.erklaerung, 'inhalt'))
self.assertTrue(hasattr(self.erklaerung, 'order'))
# Check that the fields work as expected
self.assertIsInstance(self.erklaerung.abschnitttyp, AbschnittTyp)
self.assertIsInstance(self.erklaerung.inhalt, str)
self.assertIsInstance(self.erklaerung.order, int)
class StichwortIntegrationTest(TestCase):
"""Integration tests for Stichwort app"""
def setUp(self):
"""Set up test data"""
self.stichwort1 = Stichwort.objects.create(stichwort="IT-Sicherheit")
self.stichwort2 = Stichwort.objects.create(stichwort="Datenschutz")
self.abschnitttyp = AbschnittTyp.objects.create(abschnitttyp="text")
self.erklaerung1 = Stichworterklaerung.objects.create(
erklaerung=self.stichwort1,
abschnitttyp=self.abschnitttyp,
inhalt="Erklärung für IT-Sicherheit",
order=1
)
self.erklaerung2 = Stichworterklaerung.objects.create(
erklaerung=self.stichwort2,
abschnitttyp=self.abschnitttyp,
inhalt="Erklärung für Datenschutz",
order=1
)
def test_stichwort_with_explanations_query(self):
"""Test querying Stichworte with their explanations"""
stichworte_with_explanations = Stichwort.objects.filter(
stichworterklaerung__isnull=False
).distinct()
self.assertEqual(stichworte_with_explanations.count(), 2)
self.assertIn(self.stichwort1, stichworte_with_explanations)
self.assertIn(self.stichwort2, stichworte_with_explanations)
def test_stichwort_without_explanations(self):
"""Test finding Stichworte without explanations"""
stichwort3 = Stichwort.objects.create(stichwort="Backup")
stichworte_without_explanations = Stichwort.objects.filter(
stichworterklaerung__isnull=True
)
self.assertEqual(stichworte_without_explanations.count(), 1)
self.assertEqual(stichworte_without_explanations.first(), stichwort3)
def test_explanation_count_annotation(self):
"""Test annotating Stichworte with explanation count"""
from django.db.models import Count
stichworte_with_count = Stichwort.objects.annotate(
explanation_count=Count('stichworterklaerung')
)
for stichwort in stichworte_with_count:
if stichwort.stichwort in ["IT-Sicherheit", "Datenschutz"]:
self.assertEqual(stichwort.explanation_count, 1)
else:
self.assertEqual(stichwort.explanation_count, 0)