Compare commits

...

27 Commits

Author SHA1 Message Date
32917113f2 Design suggestion from AI. Not very useable 2025-11-05 10:16:56 +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
33 changed files with 7004 additions and 319 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ package-lock.json
package.json
# Diagram cache directory
media/diagram_cache/
.env

1599
R0066.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -26,8 +26,8 @@ import referenzen.views
admin.site.site_header="Autorenumgebung"
urlpatterns = [
path('',pages.views.startseite),
path('search/',pages.views.search),
path('',pages.views.startseite, name='startseite'),
path('search/',pages.views.search, name='search'),
path('dokumente/', include("dokumente.urls")),
path('autorenumgebung/', admin.site.urls),
path('stichworte/', include("stichworte.urls")),

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.939
image: git.baumann.gr/adebaumann/vui:0.942
imagePullPolicy: Always
ports:
- containerPort: 8000

Binary file not shown.

View File

@@ -21,45 +21,75 @@ 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(NestedTabularInline):
class VorgabeLangtextInline(NestedStackedInline):
model=VorgabeLangtext
extra=0
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)
@@ -67,17 +97,31 @@ class VorgabeForm(forms.ModelForm):
model = Vorgabe
fields = '__all__'
class VorgabeInline(SortableInlineAdminMixin, NestedTabularInline): # or StackedInline for more vertical layout
class VorgabeInline(SortableInlineAdminMixin, NestedStackedInline):
model = Vorgabe
form = VorgabeForm
extra = 0
sortable_field_name = "order" # Add this - make sure your Vorgabe model has an 'order' field
#show_change_link = True
inlines = [VorgabeKurztextInline,VorgabeLangtextInline,ChecklistenfragenInline]
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(NestedTabularInline):
model=Stichworterklaerung
@@ -104,16 +148,31 @@ class PersonAdmin(admin.ModelAdmin):
@admin.register(Dokument)
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',)
}
@@ -148,10 +207,43 @@ 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(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

@@ -60,7 +60,7 @@ 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()
@@ -78,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."
@@ -86,6 +86,123 @@ 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']

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

@@ -1,109 +1,403 @@
{% extends "base.html" %}
{% block title %}{{ standard }}{% endblock %}
{% block title %}{{ standard.nummer }} {{ standard.name }}{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item"><a href="{% url 'standard_list' %}">Standards</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ standard.nummer }}</li>
{% endblock %}
{% block content %}
<h1>{{ standard.nummer }} {{ standard.name }}</h1>
{% if standard.history == True %}
<h2>Version vom {{ standard.check_date }}</h2>
{% endif %}
<!-- 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|default_if_none:"auf weiteres" }}</p>
<!-- Start Einleitung -->
{% if standard.einleitung_html %}
<h2>Einleitung</h2>
{% for typ, html in standard.einleitung_html %}
<div>{{ html|safe }}</div>
{% endfor %}
{% endif %}
<!-- End Einleitung -->
<!-- Start Geltungsbereich -->
{% if standard.geltungsbereich_html %}
<h2>Geltungsbereich</h2>
{% for typ, html in standard.geltungsbereich_html %}
<div>{{ html|safe }}</div>
{% endfor %}
{% endif %}
<!-- End Geltungsbereich -->
<h2>Vorgaben</h2>
{% for vorgabe in vorgaben %}
<!-- Start Vorgabe -->
{% if standard.history == True or vorgabe.long_status == "active" %}
<a id="{{ vorgabe.Vorgabennummer }}"></a><div class="card mb-4">
{% if vorgabe.long_status == "active"%}
<div class="card-header d-flex justify-content-between align-items-center bg-secondary text-light">
{% elif standard.history == True %}
<div class="card-header d-flex justify-content-between align-items-center bg-danger-subtle">
{% endif %}
<h3 class="h5 m-0">{{ vorgabe.Vorgabennummer }} {{ vorgabe.titel }}
{% if vorgabe.long_status != "active" and standard.history == True %}<span class="text-danger"> ({{ vorgabe.long_status}})</span>{% endif %}
</h3>
{% if vorgabe.relevanzset %}
<span class="badge bg-light text-black"> Relevanz:
{{ vorgabe.relevanzset|join:", " }}
</span>
{% endif %}
<span class="badge bg-light text-black">{{ vorgabe.thema }}</span>
<div class="row">
<!-- Main Content -->
<div class="col-lg-8">
<!-- Standard Header -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<div class="d-flex justify-content-between align-items-start">
<div>
<h1 class="h2 mb-2">{{ standard.nummer }} {{ standard.name }}</h1>
{% if standard.history == True %}
<p class="mb-0 opacity-75">Version vom {{ standard.check_date|date:"d.m.Y" }}</p>
{% endif %}
</div>
<div class="d-flex gap-2">
{% if not standard.aktiv %}
<span class="badge bg-danger">Inaktiv</span>
{% else %}
<span class="badge bg-success">Aktiv</span>
{% endif %}
</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-muted mb-2">📅 Gültigkeit</h6>
<p class="mb-3">
<strong>Von:</strong> {{ standard.gueltigkeit_von|default_if_none:"-" }}<br>
<strong>Bis:</strong> {{ standard.gueltigkeit_bis|default_if_none:"Auf weiteres" }}
</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">👥 Verantwortlich</h6>
{% if standard.autoren.all %}
<p class="mb-1"><strong>Autoren:</strong><br>
{% for autor in standard.autoren.all %}
<span class="badge bg-light text-dark me-1">{{ autor }}</span>
{% endfor %}
</p>
{% endif %}
{% if standard.pruefende.all %}
<p class="mb-3"><strong>Prüfende:</strong><br>
{% for pruefender in standard.pruefende.all %}
<span class="badge bg-light text-dark me-1">{{ pruefender }}</span>
{% endfor %}
</p>
{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<a href="{% url 'standard_json' standard.nummer %}" class="btn btn-outline btn-sm" download="{{ standard.nummer }}.json">
📄 JSON herunterladen
</a>
<button class="btn btn-outline btn-sm" onclick="window.print()">
🖨️ Drucken
</button>
</div>
</div>
</div>
<div class="card-body p-0">
<!-- Start Kurztext -->
{% comment %} KURZTEXT BLOCK {% endcomment %}
{% if vorgabe.kurztext_html.0.1 %}
<div class="p-3 mb-3 bg-light border-3" style="width: 100%;">
{% for typ, html in vorgabe.kurztext_html %}
{% if html %}
<div class="mb-2">{{ html|safe }}</div>
<!-- Table of Contents -->
<div class="toc mb-4" id="table-of-contents">
<h3>📋 Inhaltsverzeichnis</h3>
<ul class="list-unstyled">
{% if standard.einleitung_html %}
<li><a href="#einleitung">Einleitung</a></li>
{% endif %}
{% if standard.geltungsbereich_html %}
<li><a href="#geltungsbereich">Geltungsbereich</a></li>
{% endif %}
<li><a href="#vorgaben">Vorgaben ({{ vorgaben|length }})</a>
<ul class="ms-3 mt-1">
{% for vorgabe in vorgaben %}
{% if standard.history == True or vorgabe.long_status == "active" %}
<li><a href="#{{ vorgabe.Vorgabennummer }}">{{ vorgabe.Vorgabennummer }} {{ vorgabe.titel|truncatechars:50 }}</a></li>
{% endif %}
{% endfor %}
</ul>
</li>
</ul>
</div>
<!-- Einleitung -->
{% if standard.einleitung_html %}
<section id="einleitung" class="mb-5">
<div class="card">
<div class="card-header">
<h2 class="h4 mb-0">📖 Einleitung</h2>
</div>
<div class="card-body">
{% for typ, html in standard.einleitung_html %}
<div class="content-section">{{ html|safe }}</div>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<!-- Geltungsbereich -->
{% if standard.geltungsbereich_html %}
<section id="geltungsbereich" class="mb-5">
<div class="card">
<div class="card-header">
<h2 class="h4 mb-0">🎯 Geltungsbereich</h2>
</div>
<div class="card-body">
{% for typ, html in standard.geltungsbereich_html %}
<div class="content-section">{{ html|safe }}</div>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<!-- Vorgaben -->
<section id="vorgaben" class="mb-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">📝 Vorgaben</h2>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline btn-sm" onclick="toggleAllVorgaben(true)">Alle ausklappen</button>
<button type="button" class="btn btn-outline btn-sm" onclick="toggleAllVorgaben(false)">Alle einklappen</button>
</div>
</div>
{% for vorgabe in vorgaben %}
{% if standard.history == True or vorgabe.long_status == "active" %}
<div class="card mb-4 vorgabe-card" id="{{ vorgabe.Vorgabennummer }}">
<div class="card-header {% if vorgabe.long_status == "active" %}bg-success text-white{% else %}bg-secondary text-white{% endif %}"
style="cursor: pointer;"
onclick="toggleVorgabe('{{ vorgabe.Vorgabennummer }}')">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<span class="toggle-icon me-2"></span>
<h3 class="h5 m-0">
{{ vorgabe.Vorgabennummer }} {{ vorgabe.titel }}
{% if vorgabe.long_status != "active" and standard.history == True %}
<span class="badge bg-warning text-dark ms-2">{{ vorgabe.long_status }}</span>
{% endif %}
</h3>
</div>
<div class="d-flex gap-2">
{% if vorgabe.relevanzset %}
<span class="badge bg-light text-dark">
🔥 {{ vorgabe.relevanzset|join:", " }}
</span>
{% endif %}
{% if vorgabe.thema %}
<span class="badge bg-info">{{ vorgabe.thema }}</span>
{% endif %}
</div>
</div>
</div>
<div class="card-body vorgabe-content" id="content-{{ vorgabe.Vorgabennummer }}">
<!-- Kurztext -->
{% if vorgabe.kurztext_html.0.1 %}
<div class="alert alert-info mb-3">
<h6 class="alert-heading">📌 Kurztext</h6>
{% for typ, html in vorgabe.kurztext_html %}
{% if html %}
<div class="mb-2">{{ html|safe }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
<!-- Langtext -->
<div class="mb-4">
{% for typ, html in vorgabe.langtext_html %}
{% if html %}
<div class="content-section mb-3">{{ html|safe }}</div>
{% endif %}
{% endfor %}
</div>
<!-- Checklistenfragen -->
<div class="mb-4">
<h6 class="mb-3">✅ Checklistenfragen</h6>
{% if vorgabe.checklistenfragen.all %}
<div class="list-group">
{% for frage in vorgabe.checklistenfragen.all %}
<div class="list-group-item">
<div class="d-flex align-items-start">
<input type="checkbox" class="form-check-input me-2 mt-1" id="check-{{ forloop.counter }}">
<label class="form-check-label" for="check-{{ forloop.counter }}">
{{ frage.frage }}
</label>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted"><em>Keine Checklistenfragen vorhanden</em></p>
{% endif %}
</div>
<!-- Metadaten -->
<div class="border-top pt-3">
<div class="row">
<div class="col-md-6">
<h6 class="text-muted mb-2">🏷️ Stichworte</h6>
{% if vorgabe.stichworte.all %}
<div>
{% for s in vorgabe.stichworte.all %}
<a href="{% url 'stichwort_detail' stichwort=s %}" class="badge bg-light text-dark text-decoration-none me-1 mb-1">
{{ s }}
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted small mb-0">Keine Stichworte</p>
{% endif %}
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">🔗 Referenzen</h6>
{% if vorgabe.referenzpfade %}
<div class="small">
{% for ref in vorgabe.referenzpfade %}
<div class="mb-1">{{ ref|safe }}</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted small mb-0">Keine Referenzen</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Langtext -->
<div class="p-3 mb-3">
{% comment %} LANGTEXT BLOCK {% endcomment %}
{# <h5>Langtext</h5> #}
{% for typ, html in vorgabe.langtext_html %}
{% if html %}<div class="mb-3">{{ html|safe }}</div>{% endif %}
{% endfor %}
<!-- Checklistenfragen -->
{% comment %} CHECKLISTENFRAGEN BLOCK {% endcomment %}
<h5>Checklistenfragen</h5>
{% if vorgabe.checklistenfragen.all %}
<ul class="list-group">
{% for frage in vorgabe.checklistenfragen.all %}
<li class="list-group-item">{{ frage.frage }}</li>
{% endfor %}
</ul>
{% else %}
<p><em>Keine Checklistenfragen</em></p>
{% endif %}
{% comment %} STICHWORTE + REFERENZEN AT BOTTOM {% endcomment %}
<div class="mt-4 small text-muted">
<strong>Stichworte:</strong>
{% if vorgabe.stichworte.all %}
{% for s in vorgabe.stichworte.all %}
<a href="{% url 'stichwort_detail' stichwort=s %}">{{ s }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
<em>Keine</em>
{% endif %}
<br>
<strong>Referenzen:</strong>
{% if vorgabe.referenzpfade %}
{% for ref in vorgabe.referenzpfade %}
{{ ref|safe }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
<em>Keine</em>
{% endif %}
</section>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Quick Actions -->
<div class="card mb-4 sticky-top" style="top: 1rem;">
<div class="card-header">
<h5 class="mb-0">⚡ Schnellaktionen</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button class="btn btn-outline btn-sm" onclick="scrollToSection('einleitung')">
📖 Zur Einleitung
</button>
<button class="btn btn-outline btn-sm" onclick="scrollToSection('geltungsbereich')">
🎯 Zum Geltungsbereich
</button>
<button class="btn btn-outline btn-sm" onclick="scrollToSection('vorgaben')">
📝 Zu den Vorgaben
</button>
<hr>
<a href="{% url 'standard_list' %}" class="btn btn-outline btn-sm">
← Zurück zur Liste
</a>
</div>
</div>
</div>
<!-- Statistics -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">📊 Statistiken</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<div class="border-end">
<h4 class="text-primary mb-1">{{ vorgaben|length }}</h4>
<small class="text-muted">Vorgaben</small>
</div>
</div>
<div class="col-6">
<h4 class="text-success mb-1">
{% for vorgabe in vorgaben %}
{% if vorgabe.long_status == "active" %}
{% if forloop.first %}1{% else %}{{ forloop.counter }}{% endif %}
{% endif %}
{% endfor %}
</h4>
<small class="text-muted">Aktiv</small>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<!-- JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Update active TOC item on scroll
const sections = document.querySelectorAll('section[id]');
const tocLinks = document.querySelectorAll('.toc a');
function updateActiveTOC() {
let current = '';
sections.forEach(section => {
const sectionTop = section.offsetTop;
const sectionHeight = section.clientHeight;
if (pageYOffset >= sectionTop - 100) {
current = section.getAttribute('id');
}
});
tocLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + current) {
link.classList.add('active');
}
});
}
window.addEventListener('scroll', updateActiveTOC);
updateActiveTOC();
});
function toggleVorgabe(vorgabeId) {
const content = document.getElementById('content-' + vorgabeId);
const icon = document.querySelector('#' + vorgabeId + ' .toggle-icon');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
} else {
content.style.display = 'none';
icon.textContent = '▶';
}
}
function toggleAllVorgaben(expand) {
const contents = document.querySelectorAll('.vorgabe-content');
const icons = document.querySelectorAll('.toggle-icon');
contents.forEach(content => {
content.style.display = expand ? 'block' : 'none';
});
icons.forEach(icon => {
icon.textContent = expand ? '▼' : '▶';
});
}
function scrollToSection(sectionId) {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
</script>
<style>
.content-section {
line-height: 1.6;
}
.content-section h1, .content-section h2, .content-section h3 {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.content-section ul, .content-section ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.content-section li {
margin-bottom: 0.5rem;
}
.vorgabe-card {
transition: all 0.2s ease;
}
.vorgabe-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.toc a.active {
background-color: var(--primary-color);
color: white;
}
@media print {
.vorgabe-content {
display: block !important;
}
.toggle-icon {
display: none !important;
}
}
</style>
{% endblock %}

View File

@@ -1,13 +1,201 @@
{% extends "base.html" %}
{% block title %}Standards Informatiksicherheit{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item active" aria-current="page">Standards</li>
{% endblock %}
{% block content %}
<h1>Standards Informatiksicherheit</h1>
<ul>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Standards Informatiksicherheit</h1>
<div class="d-flex gap-2">
<span class="badge bg-primary">{{ dokumente|length }} Standards</span>
</div>
</div>
<!-- Filter and Search Section -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="filter-search" class="form-label">Suchen</label>
<input type="text" class="form-control" id="filter-search" placeholder="Standard durchsuchen...">
</div>
<div class="col-md-3">
<label for="filter-status" class="form-label">Status</label>
<select class="form-select" id="filter-status">
<option value="">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<div class="col-md-3">
<label for="filter-sort" class="form-label">Sortieren</label>
<select class="form-select" id="filter-sort">
<option value="nummer">Nummer</option>
<option value="name">Name</option>
<option value="gueltigkeit">Gültigkeit</option>
</select>
</div>
</div>
</div>
</div>
<!-- Standards Grid -->
<div class="row" id="standards-container">
{% for dokument in dokumente %}
<li>
<a href="{% url 'standard_detail' nummer=dokument.nummer %}">
{{ dokument.nummer }} {{ dokument.name }}
</a>
</li>
<div class="col-lg-6 col-xl-4 mb-4 standard-item"
data-nummer="{{ dokument.nummer|lower }}"
data-name="{{ dokument.name|lower }}"
data-status="{% if dokument.aktiv %}active{% else %}inactive{% endif %}">
<div class="card standard-card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<span class="standard-number">{{ dokument.nummer }}</span>
{% if not dokument.aktiv %}
<span class="badge badge-status-inactive ms-2">Inaktiv</span>
{% else %}
<span class="badge badge-status-active ms-2">Aktiv</span>
{% endif %}
</div>
<div class="dropdown">
<button class="btn btn-sm btn-outline" type="button" data-bs-toggle="dropdown">
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'standard_detail' nummer=dokument.nummer %}">Details anzeigen</a></li>
<li><a class="dropdown-item" href="{% url 'standard_json' dokument.nummer %}" download="{{ dokument.nummer }}.json">JSON herunterladen</a></li>
</ul>
</div>
</div>
<div class="card-body">
<h5 class="card-title">
<a href="{% url 'standard_detail' nummer=dokument.nummer %}" class="text-decoration-none">
{{ dokument.name }}
</a>
</h5>
<div class="standard-meta mb-3">
<div class="row g-2">
<div class="col-6">
<small class="text-muted">
<strong>Gültig von:</strong><br>
{{ dokument.gueltigkeit_von|default_if_none:"-" }}
</small>
</div>
<div class="col-6">
<small class="text-muted">
<strong>Gültig bis:</strong><br>
{{ dokument.gueltigkeit_bis|default_if_none:"Auf weiteres" }}
</small>
</div>
</div>
</div>
{% if dokument.autoren.all %}
<div class="mb-2">
<small class="text-muted">
<strong>Autoren:</strong>
{% for autor in dokument.autoren.all %}
{{ autor }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</small>
</div>
{% endif %}
{% if dokument.pruefende.all %}
<div class="mb-3">
<small class="text-muted">
<strong>Prüfende:</strong>
{% for pruefender in dokument.pruefende.all %}
{{ pruefender }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</small>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<a href="{% url 'standard_detail' nummer=dokument.nummer %}" class="btn btn-primary btn-sm">
Details anzeigen
</a>
<div class="text-muted">
<small>
{% if dokument.history %}
Version vom {{ dokument.check_date|date:"d.m.Y" }}
{% endif %}
</small>
</div>
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="text-center py-5">
<h3 class="text-muted">Keine Standards gefunden</h3>
<p class="text-muted">Es wurden keine Standards gefunden, die Ihren Kriterien entsprechen.</p>
</div>
</div>
{% endfor %}
</ul>
</div>
<!-- JavaScript for filtering and sorting -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('filter-search');
const statusSelect = document.getElementById('filter-status');
const sortSelect = document.getElementById('filter-sort');
const container = document.getElementById('standards-container');
function filterAndSort() {
const searchTerm = searchInput.value.toLowerCase();
const statusFilter = statusSelect.value;
const sortBy = sortSelect.value;
let items = Array.from(container.querySelectorAll('.standard-item'));
// Filter
items = items.filter(item => {
const nummer = item.dataset.nummer;
const name = item.dataset.name;
const status = item.dataset.status;
const matchesSearch = !searchTerm ||
nummer.includes(searchTerm) ||
name.includes(searchTerm);
const matchesStatus = !statusFilter || status === statusFilter;
return matchesSearch && matchesStatus;
});
// Sort
items.sort((a, b) => {
switch(sortBy) {
case 'nummer':
return a.dataset.nummer.localeCompare(b.dataset.nummer);
case 'name':
return a.dataset.name.localeCompare(b.dataset.name);
case 'gueltigkeit':
// This would need additional data attributes for proper sorting
return a.dataset.nummer.localeCompare(b.dataset.nummer);
default:
return 0;
}
});
// Reorder DOM
items.forEach(item => container.appendChild(item));
// Show/hide no results message
const noResults = container.querySelector('.col-12 .text-center');
if (noResults) {
noResults.parentElement.style.display = items.length === 0 ? 'block' : 'none';
}
}
searchInput.addEventListener('input', filterAndSort);
statusSelect.addEventListener('change', filterAndSort);
sortSelect.addEventListener('change', filterAndSort);
});
</script>
{% endblock %}

View File

@@ -1,11 +1,15 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.core.management import call_command
from django.contrib.auth.models import User
from datetime import date, timedelta
from io import StringIO
from .models import (
Dokumententyp, Person, Thema, Dokument, Vorgabe,
VorgabeLangtext, VorgabeKurztext, Geltungsbereich,
Einleitung, Checklistenfrage, Changelog
)
from .utils import check_vorgabe_conflicts, date_ranges_intersect, format_conflict_report
from abschnitte.models import AbschnittTyp
from referenzen.models import Referenz
from stichworte.models import Stichwort
@@ -513,3 +517,621 @@ class URLPatternsTest(TestCase):
"""Test that standard_history URL resolves correctly"""
url = reverse('standard_history', kwargs={'nummer': 'TEST-001'})
self.assertEqual(url, '/dokumente/TEST-001/history/')
class VorgabeSanityCheckTest(TestCase):
"""Test cases for Vorgabe sanity check functionality"""
def setUp(self):
"""Set up test data for sanity check tests"""
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.dokument = Dokument.objects.create(
nummer="R0066",
dokumententyp=self.dokumententyp,
name="IT Security Standard",
aktiv=True
)
self.thema = Thema.objects.create(name="Organisation")
self.base_date = date(2023, 1, 1)
# Create non-conflicting Vorgaben
self.vorgabe1 = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="First Vorgabe",
gueltigkeit_von=self.base_date,
gueltigkeit_bis=date(2023, 12, 31)
)
self.vorgabe2 = Vorgabe.objects.create(
order=2,
nummer=2,
dokument=self.dokument,
thema=self.thema,
titel="Second Vorgabe",
gueltigkeit_von=self.base_date,
gueltigkeit_bis=date(2023, 12, 31)
)
def test_date_ranges_intersect_no_overlap(self):
"""Test date_ranges_intersect with non-overlapping ranges"""
# Range 1: 2023-01-01 to 2023-06-30
# Range 2: 2023-07-01 to 2023-12-31
result = date_ranges_intersect(
date(2023, 1, 1), date(2023, 6, 30),
date(2023, 7, 1), date(2023, 12, 31)
)
self.assertFalse(result)
def test_date_ranges_intersect_with_overlap(self):
"""Test date_ranges_intersect with overlapping ranges"""
# Range 1: 2023-01-01 to 2023-06-30
# Range 2: 2023-06-01 to 2023-12-31 (overlaps in June)
result = date_ranges_intersect(
date(2023, 1, 1), date(2023, 6, 30),
date(2023, 6, 1), date(2023, 12, 31)
)
self.assertTrue(result)
def test_date_ranges_intersect_with_none_end_date(self):
"""Test date_ranges_intersect with None end date (open-ended)"""
# Range 1: 2023-01-01 to None (open-ended)
# Range 2: 2023-06-01 to 2023-12-31
result = date_ranges_intersect(
date(2023, 1, 1), None,
date(2023, 6, 1), date(2023, 12, 31)
)
self.assertTrue(result)
def test_date_ranges_intersect_both_none_end_dates(self):
"""Test date_ranges_intersect with both None end dates"""
# Both ranges are open-ended
result = date_ranges_intersect(
date(2023, 1, 1), None,
date(2023, 6, 1), None
)
self.assertTrue(result)
def test_date_ranges_intersect_identical_ranges(self):
"""Test date_ranges_intersect with identical ranges"""
result = date_ranges_intersect(
date(2023, 1, 1), date(2023, 12, 31),
date(2023, 1, 1), date(2023, 12, 31)
)
self.assertTrue(result)
def test_sanity_check_vorgaben_no_conflicts(self):
"""Test sanity_check_vorgaben with no conflicts"""
conflicts = Vorgabe.sanity_check_vorgaben()
self.assertEqual(len(conflicts), 0)
def test_sanity_check_vorgaben_with_conflicts(self):
"""Test sanity_check_vorgaben with conflicting Vorgaben"""
# Create a conflicting Vorgabe (same nummer, thema, dokument with overlapping dates)
conflicting_vorgabe = Vorgabe.objects.create(
order=3,
nummer=1, # Same as vorgabe1
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe",
gueltigkeit_von=date(2023, 6, 1), # Overlaps with vorgabe1
gueltigkeit_bis=date(2023, 8, 31)
)
conflicts = Vorgabe.sanity_check_vorgaben()
self.assertEqual(len(conflicts), 1)
conflict = conflicts[0]
self.assertEqual(conflict['conflict_type'], 'date_range_intersection')
self.assertIn('R0066.O.1', conflict['message'])
self.assertIn('überschneiden sich in der Geltungsdauer', conflict['message'])
self.assertEqual(conflict['vorgabe1'], self.vorgabe1)
self.assertEqual(conflict['vorgabe2'], conflicting_vorgabe)
def test_sanity_check_vorgaben_multiple_conflicts(self):
"""Test sanity_check_vorgaben with multiple conflict groups"""
# Create first conflict group
conflicting_vorgabe1 = Vorgabe.objects.create(
order=3,
nummer=1, # Same as vorgabe1
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe 1",
gueltigkeit_von=date(2023, 6, 1),
gueltigkeit_bis=date(2023, 8, 31)
)
# Create second conflict group with different nummer
conflicting_vorgabe2 = Vorgabe.objects.create(
order=4,
nummer=2, # Same as vorgabe2
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe 2",
gueltigkeit_von=date(2023, 6, 1),
gueltigkeit_bis=date(2023, 8, 31)
)
conflicts = Vorgabe.sanity_check_vorgaben()
self.assertEqual(len(conflicts), 2)
# Check that we have conflicts for both nummer 1 and nummer 2
conflict_messages = [c['message'] for c in conflicts]
self.assertTrue(any('R0066.O.1' in msg for msg in conflict_messages))
self.assertTrue(any('R0066.O.2' in msg for msg in conflict_messages))
def test_find_conflicts_no_conflicts(self):
"""Test find_conflicts method on Vorgabe with no conflicts"""
conflicts = self.vorgabe1.find_conflicts()
self.assertEqual(len(conflicts), 0)
def test_find_conflicts_with_conflicts(self):
"""Test find_conflicts method on Vorgabe with conflicts"""
# Create a conflicting Vorgabe
conflicting_vorgabe = Vorgabe.objects.create(
order=3,
nummer=1, # Same as vorgabe1
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe",
gueltigkeit_von=date(2023, 6, 1), # Overlaps
gueltigkeit_bis=date(2023, 8, 31)
)
conflicts = self.vorgabe1.find_conflicts()
self.assertEqual(len(conflicts), 1)
conflict = conflicts[0]
self.assertEqual(conflict['vorgabe1'], self.vorgabe1)
self.assertEqual(conflict['vorgabe2'], conflicting_vorgabe)
def test_vorgabe_clean_no_conflicts(self):
"""Test Vorgabe.clean() with no conflicts"""
try:
self.vorgabe1.clean()
except Exception as e:
self.fail(f"clean() raised {e} unexpectedly!")
def test_vorgabe_clean_with_conflicts(self):
"""Test Vorgabe.clean() with conflicts raises ValidationError"""
# Create a conflicting Vorgabe
conflicting_vorgabe = Vorgabe(
order=3,
nummer=1, # Same as vorgabe1
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe",
gueltigkeit_von=date(2023, 6, 1), # Overlaps
gueltigkeit_bis=date(2023, 8, 31)
)
with self.assertRaises(Exception) as context:
conflicting_vorgabe.clean()
self.assertIn('Konflikt mit bestehender', str(context.exception))
self.assertIn('Geltungsdauer übeschneidet sich', str(context.exception))
def test_check_vorgabe_conflicts_utility(self):
"""Test check_vorgabe_conflicts utility function"""
# Initially no conflicts
conflicts = check_vorgabe_conflicts()
self.assertEqual(len(conflicts), 0)
# Create a conflicting Vorgabe
conflicting_vorgabe = Vorgabe.objects.create(
order=3,
nummer=1, # Same as vorgabe1
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe",
gueltigkeit_von=date(2023, 6, 1), # Overlaps
gueltigkeit_bis=date(2023, 8, 31)
)
conflicts = check_vorgabe_conflicts()
self.assertEqual(len(conflicts), 1)
def test_format_conflict_report_no_conflicts(self):
"""Test format_conflict_report with no conflicts"""
report = format_conflict_report([])
self.assertEqual(report, "✓ No conflicts found in Vorgaben")
def test_format_conflict_report_with_conflicts(self):
"""Test format_conflict_report with conflicts"""
# Create a conflicting Vorgabe
conflicting_vorgabe = Vorgabe.objects.create(
order=3,
nummer=1, # Same as vorgabe1
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe",
gueltigkeit_von=date(2023, 6, 1), # Overlaps
gueltigkeit_bis=date(2023, 8, 31)
)
conflicts = check_vorgabe_conflicts()
report = format_conflict_report(conflicts)
self.assertIn("Found 1 conflicts:", report)
self.assertIn("R0066.O.1", report)
self.assertIn("intersecting validity periods", report)
class SanityCheckManagementCommandTest(TestCase):
"""Test cases for sanity_check_vorgaben management command"""
def setUp(self):
"""Set up test data for management command tests"""
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.dokument = Dokument.objects.create(
nummer="R0066",
dokumententyp=self.dokumententyp,
name="IT Security Standard",
aktiv=True
)
self.thema = Thema.objects.create(name="Organisation")
def test_sanity_check_command_no_conflicts(self):
"""Test management command with no conflicts"""
out = StringIO()
call_command('sanity_check_vorgaben', stdout=out)
output = out.getvalue()
self.assertIn("Starting Vorgaben sanity check...", output)
self.assertIn("✓ No conflicts found in Vorgaben", output)
def test_sanity_check_command_with_conflicts(self):
"""Test management command with conflicts"""
# Create conflicting Vorgaben
Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="First Vorgabe",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2023, 12, 31)
)
Vorgabe.objects.create(
order=2,
nummer=1, # Same nummer, thema, dokument
dokument=self.dokument,
thema=self.thema,
titel="Conflicting Vorgabe",
gueltigkeit_von=date(2023, 6, 1), # Overlaps
gueltigkeit_bis=date(2023, 8, 31)
)
out = StringIO()
call_command('sanity_check_vorgaben', stdout=out)
output = out.getvalue()
self.assertIn("Starting Vorgaben sanity check...", output)
self.assertIn("Found 1 conflicts:", output)
self.assertIn("R0066.O.1", output)
self.assertIn("überschneiden sich in der Geltungsdauer", output)
class IncompleteVorgabenTest(TestCase):
"""Test cases for incomplete Vorgaben functionality"""
def setUp(self):
self.client = Client()
# Create and login a staff user
self.staff_user = User.objects.create_user(
username='teststaff',
password='testpass123'
)
self.staff_user.is_staff = True
self.staff_user.save()
self.client.login(username='teststaff', password='testpass123')
# 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
)
# Create complete Vorgabe (should not appear in any list)
self.complete_vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="Vollständige Vorgabe",
gueltigkeit_von=date.today()
)
# Add all required components to make it complete
self.stichwort = Stichwort.objects.create(
stichwort="Test Stichwort"
)
self.complete_vorgabe.stichworte.add(self.stichwort)
self.referenz = Referenz.objects.create(
name_nummer="Test Referenz",
url="/test/path"
)
self.complete_vorgabe.referenzen.add(self.referenz)
VorgabeKurztext.objects.create(
abschnitt=self.complete_vorgabe,
inhalt="Test Kurztext"
)
Checklistenfrage.objects.create(
vorgabe=self.complete_vorgabe,
frage="Test Frage"
)
# Create incomplete Vorgaben
# 1. Vorgabe without references
self.no_refs_vorgabe = Vorgabe.objects.create(
order=2,
nummer=2,
dokument=self.dokument,
thema=self.thema,
titel="Vorgabe ohne Referenzen",
gueltigkeit_von=date.today()
)
self.no_refs_vorgabe.stichworte.add(self.stichwort)
VorgabeKurztext.objects.create(
abschnitt=self.no_refs_vorgabe,
inhalt="Test Kurztext"
)
Checklistenfrage.objects.create(
vorgabe=self.no_refs_vorgabe,
frage="Test Frage"
)
# 2. Vorgabe without Stichworte
self.no_stichworte_vorgabe = Vorgabe.objects.create(
order=3,
nummer=3,
dokument=self.dokument,
thema=self.thema,
titel="Vorgabe ohne Stichworte",
gueltigkeit_von=date.today()
)
self.no_stichworte_vorgabe.referenzen.add(self.referenz)
VorgabeKurztext.objects.create(
abschnitt=self.no_stichworte_vorgabe,
inhalt="Test Kurztext"
)
Checklistenfrage.objects.create(
vorgabe=self.no_stichworte_vorgabe,
frage="Test Frage"
)
# 3. Vorgabe without text
self.no_text_vorgabe = Vorgabe.objects.create(
order=4,
nummer=4,
dokument=self.dokument,
thema=self.thema,
titel="Vorgabe ohne Text",
gueltigkeit_von=date.today()
)
self.no_text_vorgabe.stichworte.add(self.stichwort)
self.no_text_vorgabe.referenzen.add(self.referenz)
Checklistenfrage.objects.create(
vorgabe=self.no_text_vorgabe,
frage="Test Frage"
)
# 4. Vorgabe without Checklistenfragen
self.no_checklisten_vorgabe = Vorgabe.objects.create(
order=5,
nummer=5,
dokument=self.dokument,
thema=self.thema,
titel="Vorgabe ohne Checklistenfragen",
gueltigkeit_von=date.today()
)
self.no_checklisten_vorgabe.stichworte.add(self.stichwort)
self.no_checklisten_vorgabe.referenzen.add(self.referenz)
VorgabeKurztext.objects.create(
abschnitt=self.no_checklisten_vorgabe,
inhalt="Test Kurztext"
)
def test_incomplete_vorgaben_page_status(self):
"""Test that the incomplete Vorgaben page loads successfully"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertEqual(response.status_code, 200)
def test_incomplete_vorgaben_page_content(self):
"""Test that the page contains expected content"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'Unvollständige Vorgaben')
self.assertContains(response, 'Referenzen')
self.assertContains(response, 'Stichworte')
self.assertContains(response, 'Text')
self.assertContains(response, 'Checklistenfragen')
# Check for table structure
self.assertContains(response, '<table class="table table-striped table-hover">')
self.assertContains(response, '<th class="text-center">Referenzen</th>')
def test_no_references_list(self):
"""Test that Vorgaben without references are listed"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'Vorgabe ohne Referenzen')
self.assertNotContains(response, 'Vollständige Vorgabe') # Should not appear
def test_no_stichworte_list(self):
"""Test that Vorgaben without Stichworte are listed"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'Vorgabe ohne Stichworte')
self.assertNotContains(response, 'Vollständige Vorgabe') # Should not appear
def test_no_text_list(self):
"""Test that Vorgaben without Kurz- or Langtext are listed"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'Vorgabe ohne Text')
self.assertNotContains(response, 'Vollständige Vorgabe') # Should not appear
def test_no_checklistenfragen_list(self):
"""Test that Vorgaben without Checklistenfragen are listed"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'Vorgabe ohne Checklistenfragen')
self.assertNotContains(response, 'Vollständige Vorgabe') # Should not appear
def test_vorgabe_links(self):
"""Test that Vorgaben link to their admin pages"""
response = self.client.get(reverse('incomplete_vorgaben'))
# Should contain links to Vorgabe admin pages
self.assertContains(response, 'href="/autorenumgebung/dokumente/vorgabe/2/change/"')
self.assertContains(response, 'href="/autorenumgebung/dokumente/vorgabe/3/change/"')
self.assertContains(response, 'href="/autorenumgebung/dokumente/vorgabe/4/change/"')
self.assertContains(response, 'href="/autorenumgebung/dokumente/vorgabe/5/change/"')
def test_badge_counts(self):
"""Test that badge counts are correct"""
response = self.client.get(reverse('incomplete_vorgaben'))
# Check that JavaScript updates the counts correctly
self.assertContains(response, 'id="no-references-count"')
self.assertContains(response, 'id="no-stichworte-count"')
self.assertContains(response, 'id="no-text-count"')
self.assertContains(response, 'id="no-checklistenfragen-count"')
# Check total count
self.assertContains(response, 'Gesamt: 4 unvollständige Vorgaben')
def test_summary_section(self):
"""Test that summary section shows correct counts"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'Zusammenfassung')
self.assertContains(response, 'Ohne Referenzen')
self.assertContains(response, 'Ohne Stichworte')
self.assertContains(response, 'Ohne Text')
self.assertContains(response, 'Ohne Checklistenfragen')
self.assertContains(response, 'Gesamt: 4 unvollständige Vorgaben')
def test_empty_lists_message(self):
"""Test that appropriate messages are shown when lists are empty"""
# Delete all incomplete Vorgaben
Vorgabe.objects.exclude(pk=self.complete_vorgabe.pk).delete()
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'Alle Vorgaben sind vollständig!')
self.assertContains(response, 'Alle Vorgaben haben Referenzen, Stichworte, Text und Checklistenfragen.')
def test_back_link(self):
"""Test that back link to standard list exists"""
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertContains(response, 'href="/dokumente/"')
self.assertContains(response, 'Zurück zur Übersicht')
def test_navigation_link(self):
"""Test that navigation includes link to incomplete Vorgaben"""
response = self.client.get('/dokumente/')
self.assertContains(response, 'href="/dokumente/unvollstaendig/"')
self.assertContains(response, 'Unvollständig')
def test_vorgabe_with_langtext_only(self):
"""Test that Vorgabe with only Langtext is still considered incomplete for text"""
vorgabe_langtext_only = Vorgabe.objects.create(
order=6,
nummer=6,
dokument=self.dokument,
thema=self.thema,
titel="Vorgabe nur mit Langtext",
gueltigkeit_von=date.today()
)
vorgabe_langtext_only.stichworte.add(self.stichwort)
vorgabe_langtext_only.referenzen.add(self.referenz)
# Add only Langtext, no Kurztext
VorgabeLangtext.objects.create(
abschnitt=vorgabe_langtext_only,
inhalt="Test Langtext"
)
# Add Checklistenfragen to make it complete in that aspect
Checklistenfrage.objects.create(
vorgabe=vorgabe_langtext_only,
frage="Test Frage"
)
response = self.client.get(reverse('incomplete_vorgaben'))
# Debug: print response content to see where it appears
print("Response content:", response.content.decode())
# Should NOT appear in "no text" list because it has Langtext
self.assertNotContains(response, 'Vorgabe nur mit Langtext')
def test_vorgabe_with_both_text_types(self):
"""Test that Vorgabe with both Kurztext and Langtext is complete"""
vorgabe_both_text = Vorgabe.objects.create(
order=7,
nummer=7,
dokument=self.dokument,
thema=self.thema,
titel="Vorgabe mit beiden Texten",
gueltigkeit_von=date.today()
)
vorgabe_both_text.stichworte.add(self.stichwort)
vorgabe_both_text.referenzen.add(self.referenz)
# Add both Kurztext and Langtext
VorgabeKurztext.objects.create(
abschnitt=vorgabe_both_text,
inhalt="Test Kurztext"
)
VorgabeLangtext.objects.create(
abschnitt=vorgabe_both_text,
inhalt="Test Langtext"
)
# Add Checklistenfragen to make it complete in that aspect
Checklistenfrage.objects.create(
vorgabe=vorgabe_both_text,
frage="Test Frage"
)
response = self.client.get(reverse('incomplete_vorgaben'))
# Should NOT appear in "no text" list because it has both text types
self.assertNotContains(response, 'Vorgabe mit beiden Texten')
def test_incomplete_vorgaben_staff_only(self):
"""Test that non-staff users are redirected to login"""
# Logout the staff user
self.client.logout()
# Try to access the page as anonymous user
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertEqual(response.status_code, 302) # Redirect to login
# Create a regular (non-staff) user
regular_user = User.objects.create_user(
username='regularuser',
password='testpass123'
)
self.client.login(username='regularuser', password='testpass123')
# Try to access the page as regular user
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertEqual(response.status_code, 302) # Redirect to login
# Login as staff user again - should work
self.client.login(username='teststaff', password='testpass123')
response = self.client.get(reverse('incomplete_vorgaben'))
self.assertEqual(response.status_code, 200) # Success

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

@@ -1,33 +1,160 @@
<!DOCTYPE html>
<html>
<html lang="de" data-theme="light">
<head>
<meta charset="utf-8">
<title>{% block title %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Vorgaben Informatiksicherheit BIT{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% load static %}
<link rel="stylesheet" href="{% static 'custom/css/vorgaben-ui.css' %}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">Vorgaben</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<a class="nav-item nav-link active" href="/dokumente">Standards</a>
<a class="nav-item nav-link" href="/referenzen">Referenzen</a>
<a class="nav-item nav-link" href="/stichworte">Stichworte</a>
<a class="nav-item nav-link" href="/search">Suche</a>
<!-- Enhanced Navigation -->
<nav class="navbar navbar-expand-lg sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="/">
Vorgaben Informatiksicherheit
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Navigation umschalten">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'standard_list' %}">Standards</a>
</li>
{% if user.is_staff %}
<li class="nav-item">
<a class="nav-link" href="{% url 'incomplete_standards' %}">Unvollständig</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{% url 'referenz_tree' %}">Referenzen</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'stichworte_list' %}">Stichworte</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'search' %}">Suche</a>
</li>
</ul>
<!-- Search Bar -->
<form class="navbar-search d-none d-lg-flex me-3" action="/search/" method="get">
<input type="text" name="q" placeholder="Suchen..." value="{{ search_term|default:'' }}">
<button type="submit" aria-label="Suchen">🔍</button>
</form>
<!-- Dark Mode Toggle -->
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Dark Mode umschalten">
<span id="theme-icon">🌙</span>
</button>
</div>
</div>
</nav>
<div class="d-flex">
<div class="col-md-2">{% block sidebar_left %}{% endblock %}</div>
<div class="flex-fill">{% block content %}Main Content{% endblock %}</div>
<div class="col-md-2">{% block sidebar_right %}{% endblock %}</div>
<!-- Breadcrumb -->
{% block breadcrumb %}
{% if request.resolver_match.url_name != 'startseite' and request.path != '/' %}
<nav aria-label="Breadcrumb">
<div class="container-fluid">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Startseite</a></li>
{% block breadcrumb_items %}{% endblock %}
</ol>
</div>
</nav>
{% endif %}
{% endblock %}
<!-- Main Content -->
<div class="container-fluid">
<div class="row">
<!-- Left Sidebar -->
{% if block.sidebar_left %}
<aside class="col-lg-2 d-none d-lg-block">
{% block sidebar_left %}{% endblock %}
</aside>
{% endif %}
<!-- Main Content Area -->
<main class="{% if block.sidebar_left or block.sidebar_right %}col-lg-8{% else %}col-lg-12{% endif %} py-4">
{% block content %}{% endblock %}
</main>
<!-- Right Sidebar -->
{% if block.sidebar_right %}
<aside class="col-lg-2 d-none d-lg-block">
{% block sidebar_right %}{% endblock %}
</aside>
{% endif %}
</div>
</div>
<div>VorgabenUI v0.939</div>
<!-- Footer -->
<footer class="footer">
<div class="container-fluid">
<p class="mb-0">VorgabenUI v0.942 | © {{ "now"|date:"Y" }} Bundesamt für Informatik</p>
</div>
</footer>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Theme Toggle
function toggleTheme() {
const html = document.documentElement;
const themeIcon = document.getElementById('theme-icon');
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
themeIcon.textContent = newTheme === 'light' ? '🌙' : '☀️';
localStorage.setItem('theme', newTheme);
}
// Load saved theme
document.addEventListener('DOMContentLoaded', function() {
const savedTheme = localStorage.getItem('theme') || 'light';
const html = document.documentElement;
const themeIcon = document.getElementById('theme-icon');
html.setAttribute('data-theme', savedTheme);
themeIcon.textContent = savedTheme === 'light' ? '🌙' : '☀️';
});
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Active navigation highlighting
document.addEventListener('DOMContentLoaded', function() {
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
navLinks.forEach(link => {
if (link.getAttribute('href') === currentPath) {
link.classList.add('active');
}
});
});
</script>
</body>
</html>

View File

@@ -1,30 +1,160 @@
{% extends "base.html" %}
{% load page_extras %}
{% block title %}Suchresultate für {{ suchbegriff }}{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item"><a href="/search/">Suche</a></li>
<li class="breadcrumb-item active" aria-current="page">Resultate</li>
{% endblock %}
{% block content %}
<h1 class="mb-4">Suchresultate für {{ suchbegriff }}</h1>
{% if resultat.geltungsbereich %}
<h2>Standards mit "{{suchbegriff}}" im Geltungsbereich</h2>
{% for standard,geltungsbereich in resultat.geltungsbereich.items %}
<h4>{{ standard }}</h4>
{% for typ, html in geltungsbereich %}
<div>{{ html|highlighttext:suchbegriff|safe }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<div class="row">
<div class="col-lg-4">
<!-- Search Form -->
<div class="card search-form sticky-top" style="top: 1rem;">
<div class="card-header">
<h5 class="mb-0">🔍 Suche</h5>
</div>
<div class="card-body">
<form action="/search/" method="post" id="search-form">
{% csrf_token %}
<!-- Main Search Field -->
<div class="mb-3">
<label for="query" class="form-label">Suchbegriff</label>
<div class="input-group">
<input type="text"
class="form-control"
id="query"
name="q"
placeholder="Suchbegriff eingeben …"
value="{{ suchbegriff|default:'' }}"
required
maxlength="200">
<button class="btn btn-primary" type="submit">
Suchen
</button>
</div>
</div>
{% if resultat.all %}
<h2>Vorgaben mit "{{ suchbegriff }}"</h2>
{% for standard, vorgaben in resultat.all.items %}
<h4>{{ standard }}</h4>
<ul>
{% for vorgabe in vorgaben %}
<li><a href="{% url 'standard_detail' nummer=vorgabe.dokument.nummer %}#{{vorgabe.Vorgabennummer}}">{{vorgabe}}</a></li>
<!-- Search Options -->
<div class="mb-3">
<label class="form-label">Suchbereiche</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="search_in" value="standards" id="search-standards" checked>
<label class="form-check-label" for="search-standards">
Standards
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="search_in" value="geltungsbereich" id="search-geltungsbereich" checked>
<label class="form-check-label" for="search-geltungsbereich">
Geltungsbereich
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="search_in" value="vorgaben" id="search-vorgaben" checked>
<label class="form-check-label" for="search-vorgaben">
Vorgaben
</label>
</div>
</div>
<button class="btn btn-outline btn-sm w-100" onclick="history.back()">
← Zurück zur Suche
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<!-- Search Results -->
<div class="search-results">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Suchresultate für "{{ suchbegriff }}"</h2>
<span class="badge bg-primary">
{% if resultat.geltungsbereich %}
{{ resultat.geltungsbereich|length }}
{% endif %}
{% if resultat.all %}
{% for standard, vorgaben in resultat.all.items %}
{{ vorgaben|length }}
{% endfor %}
{% endif %}
Ergebnisse
</span>
</div>
<!-- Geltungsbereich Results -->
{% if resultat.geltungsbereich %}
<div class="mb-5">
<h3 class="h4 mb-3">📋 Standards mit "{{ suchbegriff }}" im Geltungsbereich</h3>
{% for standard, geltungsbereich in resultat.geltungsbereich.items %}
<div class="result-item">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="mb-1">
<a href="/dokumente/{{ standard.nummer }}/" class="text-decoration-none">
{{ standard.nummer }} {{ standard.name }}
</a>
</h5>
<span class="badge bg-info">Geltungsbereich</span>
</div>
{% for typ, html in geltungsbereich %}
<div class="search-context">{{ html|highlighttext:suchbegriff|safe }}</div>
{% endfor %}
</div>
{% endfor %}
</ul>
{% endfor %}
{% endif %}
</div>
{% endif %}
{% if not resultat.all %}
<h2>Keine Resultate für "{{suchbegriff}}"</h2>
{% endif %}
<!-- Vorgaben Results -->
{% if resultat.all %}
<div class="mb-5">
<h3 class="h4 mb-3">📝 Vorgaben mit "{{ suchbegriff }}"</h3>
{% for standard, vorgaben in resultat.all.items %}
<div class="result-item">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="mb-1">
<a href="/dokumente/{{ standard.nummer }}/" class="text-decoration-none">
{{ standard }}
</a>
</h5>
<span class="badge bg-success">{{ vorgaben|length }} Vorgaben</span>
</div>
<div class="row">
{% for vorgabe in vorgaben %}
<div class="col-md-6 mb-2">
<div class="d-flex align-items-center">
<span class="badge bg-secondary me-2">{{ vorgabe.Vorgabennummer }}</span>
<a href="/dokumente/{{ vorgabe.dokument.nummer }}/#{{vorgabe.Vorgabennummer}}"
class="text-decoration-none">
{{ vorgabe.titel }}
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- No Results -->
{% if not resultat.geltungsbereich and not resultat.all %}
<div class="text-center py-5">
<div class="mb-3">
<span style="font-size: 3rem;">🔍</span>
</div>
<h3 class="text-muted">Keine Resultate für "{{ suchbegriff }}"</h3>
<p class="text-muted mb-4">
Es wurden keine Ergebnisse gefunden. Versuchen Sie es mit anderen Suchbegriffen.
</p>
<button class="btn btn-primary" onclick="history.back()">
← Zurück zur Suche
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,20 +1,139 @@
{% extends "base.html" %}
{% block content %}
<h1 class="mb-4">Suche</h1>
{% block title %}Suche{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item active" aria-current="page">Suche</li>
{% endblock %}
<!-- Search form -->
<form action="." method="post">
{% csrf_token %}
<!-- Search field -->
<div class="mb-3">
<label for="query" class="form-label">Suchbegriff</label>
<input type="text"
class="form-control"
id="query"
name="q"
placeholder="Suchbegriff eingeben …"
required>
{% block content %}
<div class="row">
<div class="col-lg-4">
<!-- Search Form -->
<div class="card search-form sticky-top" style="top: 1rem;">
<div class="card-header">
<h5 class="mb-0">🔍 Suche</h5>
</div>
<button type="submit" class="btn btn-primary">Suchen</button>
</form>
{% endblock %}
<div class="card-body">
<form action="/search/" method="post" id="search-form">
{% csrf_token %}
<!-- Main Search Field -->
<div class="mb-3">
<label for="query" class="form-label">Suchbegriff</label>
<div class="input-group">
<input type="text"
class="form-control"
id="query"
name="q"
placeholder="Suchbegriff eingeben …"
value="{{ search_term|default:'' }}"
required
maxlength="200">
<button class="btn btn-primary" type="submit">
Suchen
</button>
</div>
</div>
<button class="btn btn-outline btn-sm w-100" onclick="history.back()">
← Zurück zur Suche
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<!-- Search Results -->
{% if search_term %}
<div class="search-results">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Suchresultate für "{{ search_term }}"</h2>
<span class="badge bg-primary">
{% if resultat.geltungsbereich %}
{{ resultat.geltungsbereich|length }}
{% endif %}
{% if resultat.all %}
{% for standard, vorgaben in resultat.all.items %}
{{ vorgaben|length }}
{% endfor %}
{% endif %}
Ergebnisse
</span>
</div>
<!-- No Results -->
{% if not resultat.geltungsbereich and not resultat.all %}
<div class="text-center py-5">
<div class="mb-3">
<span style="font-size: 3rem;">🔍</span>
</div>
<h3 class="text-muted">Keine Resultate für "{{ search_term }}"</h3>
<p class="text-muted mb-4">
Es wurden keine Ergebnisse gefunden. Versuchen Sie es mit anderen Suchbegriffen.
</p>
<button class="btn btn-primary" onclick="history.back()">
← Zurück zur Suche
</button>
</div>
{% endif %}
</div>
{% else %}
<!-- Initial Search State -->
<div class="text-center py-5">
<div class="mb-4">
<span style="font-size: 4rem;">🔍</span>
</div>
<h2>Willkommen bei der Suche</h2>
<p class="text-muted mb-4">
Geben Sie einen Suchbegriff ein, um Standards, Vorgaben und Geltungsbereiche zu durchsuchen.
</p>
<!-- Quick Search Examples -->
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Beispielsuchen</h6>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-md-4">
<button class="btn btn-outline btn-sm w-100 quick-search" data-term="Passwort">
🔐 Passwort
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline btn-sm w-100 quick-search" data-term="Netzwerk">
🌐 Netzwerk
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline btn-sm w-100 quick-search" data-term="Verschlüsselung">
🔒 Verschlüsselung
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- JavaScript for quick search -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Quick search buttons
const quickSearchButtons = document.querySelectorAll('.quick-search');
quickSearchButtons.forEach(button => {
button.addEventListener('click', function() {
const term = this.dataset.term;
document.getElementById('query').value = term;
document.getElementById('search-form').submit();
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Suche{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item active" aria-current="page">Suche</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-4">
<!-- Search Form -->
<div class="card search-form sticky-top" style="top: 1rem;">
<div class="card-header">
<h5 class="mb-0">🔍 Suche</h5>
</div>
<div class="card-body">
<form action="/search/" method="post" id="search-form">
{% csrf_token %}
<!-- Main Search Field -->
<div class="mb-3">
<label for="query" class="form-label">Suchbegriff</label>
<div class="input-group">
<input type="text"
class="form-control"
id="query"
name="q"
placeholder="Suchbegriff eingeben …"
value="{{ search_term|default:'' }}"
required
maxlength="200">
<button class="btn btn-primary" type="submit">
Suchen
</button>
</div>
</div>
<button class="btn btn-outline btn-sm w-100" onclick="history.back()">
← Zurück zur Suche
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<!-- Search Results -->
{% if search_term %}
<div class="search-results">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Suchresultate für "{{ search_term }}"</h2>
<span class="badge bg-primary">
{% if resultat.geltungsbereich %}
{{ resultat.geltungsbereich|length }}
{% endif %}
{% if resultat.all %}
{% for standard, vorgaben in resultat.all.items %}
{{ vorgaben|length }}
{% endfor %}
{% endif %}
Ergebnisse
</span>
</div>
<!-- No Results -->
{% if not resultat.geltungsbereich and not resultat.all %}
<div class="text-center py-5">
<div class="mb-3">
<span style="font-size: 3rem;">🔍</span>
</div>
<h3 class="text-muted">Keine Resultate für "{{ search_term }}"</h3>
<p class="text-muted mb-4">
Es wurden keine Ergebnisse gefunden. Versuchen Sie es mit anderen Suchbegriffen.
</p>
<button class="btn btn-primary" onclick="history.back()">
← Zurück zur Suche
</button>
</div>
{% endif %}
</div>
{% else %}
<!-- Initial Search State -->
<div class="text-center py-5">
<div class="mb-4">
<span style="font-size: 4rem;">🔍</span>
</div>
<h2>Willkommen bei der Suche</h2>
<p class="text-muted mb-4">
Geben Sie einen Suchbegriff ein, um Standards, Vorgaben und Geltungsbereiche zu durchsuchen.
</p>
<!-- Quick Search Examples -->
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Beispielsuchen</h6>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-md-4">
<button class="btn btn-outline btn-sm w-100 quick-search" data-term="Passwort">
🔐 Passwort
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline btn-sm w-100 quick-search" data-term="Netzwerk">
🌐 Netzwerk
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline btn-sm w-100 quick-search" data-term="Verschlüsselung">
🔒 Verschlüsselung
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- JavaScript for quick search -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Quick search buttons
const quickSearchButtons = document.querySelectorAll('.quick-search');
quickSearchButtons.forEach(button => {
button.addEventListener('click', function() {
const term = this.dataset.term;
document.getElementById('query').value = term;
document.getElementById('search-form').submit();
});
});
});
</script>
{% endblock %}

View File

@@ -1,10 +1,335 @@
{% extends "base.html" %}
{% block title %}Startseite - Vorgaben Informatiksicherheit BIT{% endblock %}
{% block content %}
<h1>Vorgaben Informatiksicherheit BIT</h1>
<h2>Aktuell erfasste Standards</h2>
<ul>
{% for standard in dokumente %}
<li><a href="{% url 'standard_detail' nummer=standard.nummer %}">{{ standard }}</a></li>
{% endfor %}
</ul>
<!-- Hero Section -->
<div class="card bg-primary text-white mb-5">
<div class="card-body text-center py-5">
<h1 class="display-4 mb-3">🔒 Vorgaben Informatiksicherheit BIT</h1>
<p class="lead mb-4">
Zentraler Zugang zu allen Sicherheitsstandards, Vorgaben und Richtlinien des Bundesamtes für Informatik
</p>
<div class="d-flex justify-content-center gap-3">
<a href="/dokumente/" class="btn btn-light btn-lg">
📋 Standards durchsuchen
</a>
<a href="/search/" class="btn btn-outline-light btn-lg">
🔍 Volltextsuche
</a>
</div>
</div>
</div>
<!-- Statistics Dashboard -->
<div class="row mb-5">
<div class="col-lg-3 col-md-6 mb-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="mb-3">
<span style="font-size: 2.5rem;">📋</span>
</div>
<h3 class="h2 text-primary mb-2">{{ dokumente|length }}</h3>
<p class="text-muted mb-0">Standards</p>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="mb-3">
<span style="font-size: 2.5rem;">📝</span>
</div>
<h3 class="h2 text-success mb-2">
{% if total_vorgaben %}{{ total_vorgaben }}{% else %}--{% endif %}
</h3>
<p class="text-muted mb-0">Vorgaben</p>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="mb-3">
<span style="font-size: 2.5rem;">🏷️</span>
</div>
<h3 class="h2 text-info mb-2">
{% if total_stichworte %}{{ total_stichworte }}{% else %}--{% endif %}
</h3>
<p class="text-muted mb-0">Stichworte</p>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="mb-3">
<span style="font-size: 2.5rem;">🔗</span>
</div>
<h3 class="h2 text-warning mb-2">
{% if total_referenzen %}{{ total_referenzen }}{% else %}--{% endif %}
</h3>
<p class="text-muted mb-0">Referenzen</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-5">
<div class="col-12">
<h2 class="h4 mb-4">⚡ Schnellzugriffe</h2>
</div>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body text-center">
<div class="mb-3">
<span style="font-size: 2rem;">📋</span>
</div>
<h5 class="card-title">Standards</h5>
<p class="card-text text-muted">
Alle Sicherheitsstandards durchsuchen und filtern
</p>
<a href="/dokumente/" class="btn btn-primary">
Standards anzeigen
</a>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body text-center">
<div class="mb-3">
<span style="font-size: 2rem;">🔍</span>
</div>
<h5 class="card-title">Suche</h5>
<p class="card-text text-muted">
Volltextsuche in allen Standards und Vorgaben
</p>
<a href="/search/" class="btn btn-primary">
Suche starten
</a>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body text-center">
<div class="mb-3">
<span style="font-size: 2rem;">🏷️</span>
</div>
<h5 class="card-title">Stichworte</h5>
<p class="card-text text-muted">
Nach Stichworten browsen und entdecken
</p>
<a href="/stichworte/" class="btn btn-primary">
Stichworte anzeigen
</a>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body text-center">
<div class="mb-3">
<span style="font-size: 2rem;">🔗</span>
</div>
<h5 class="card-title">Referenzen</h5>
<p class="card-text text-muted">
Referenzbaum und Querverbindungen
</p>
<a href="/referenzen/" class="btn btn-primary">
Referenzen anzeigen
</a>
</div>
</div>
</div>
{% if user.is_staff %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body text-center">
<div class="mb-3">
<span style="font-size: 2rem;">⚠️</span>
</div>
<h5 class="card-title">Unvollständig</h5>
<p class="card-text text-muted">
Unvollständige Standards bearbeiten
</p>
<a href="/dokumente/unvollstaendig/" class="btn btn-warning">
Bearbeiten
</a>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Recent Standards -->
<div class="row mb-5">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h3 class="h5 mb-0">📋 Aktuell erfasste Standards</h3>
<a href="/dokumente/" class="btn btn-outline btn-sm">
Alle anzeigen →
</a>
</div>
</div>
<div class="card-body">
{% if dokumente %}
<div class="row">
{% for standard in dokumente|slice:":6" %}
<div class="col-lg-4 col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title">
<a href="{% url 'standard_detail' nummer=standard.nummer %}" class="text-decoration-none">
{{ standard.nummer }} {{ standard.name|truncatechars:40 }}
</a>
</h6>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{{ standard.gueltigkeit_von|default_if_none:"-" }}
</small>
{% if not standard.aktiv %}
<span class="badge bg-danger">Inaktiv</span>
{% else %}
<span class="badge bg-success">Aktiv</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-4">
<p class="text-muted mb-0">Keine Standards gefunden.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Quick Search -->
<div class="row mb-5">
<div class="col-12">
<div class="card bg-light">
<div class="card-body text-center py-4">
<h3 class="h5 mb-3">🔍 Schnellsuche</h3>
<form action="/search/" method="get" class="row justify-content-center">
<div class="col-md-6">
<div class="input-group">
<input type="text"
class="form-control"
name="q"
placeholder="Suchbegriff eingeben..."
maxlength="200">
<button class="btn btn-primary" type="submit">
Suchen
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Help Section -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="h5 mb-0">💡 Hinweise zur Nutzung</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<h6 class="text-primary">🔍 Suchen</h6>
<p class="small text-muted mb-0">
Nutzen Sie die Volltextsuche um gezielt nach Begriffen in allen Standards zu suchen.
</p>
</div>
<div class="col-md-4 mb-3">
<h6 class="text-primary">📋 Filtern</h6>
<p class="small text-muted mb-0">
Filtern Sie Standards nach Status, Gültigkeit oder anderen Kriterien.
</p>
</div>
<div class="col-md-4 mb-3">
<h6 class="text-primary">🏷️ Stichworte</h6>
<p class="small text-muted mb-0">
Entdecken Sie verwandte Inhalte durch die Stichwort-Navigation.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript for dynamic interactions -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add hover effects to cards
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-2px)';
this.style.transition = 'all 0.2s ease';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
});
});
// Animate statistics on page load
const statNumbers = document.querySelectorAll('.h2');
statNumbers.forEach((stat, index) => {
setTimeout(() => {
stat.style.opacity = '0';
stat.style.transform = 'scale(0.8)';
stat.style.transition = 'all 0.5s ease';
setTimeout(() => {
stat.style.opacity = '1';
stat.style.transform = 'scale(1)';
}, 100);
}, index * 100);
});
});
</script>
<style>
.card {
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.display-4 {
font-weight: 700;
}
.btn-lg {
padding: 0.75rem 2rem;
font-weight: 500;
}
@media (max-width: 768px) {
.display-4 {
font-size: 2rem;
}
.btn-lg {
padding: 0.5rem 1.5rem;
font-size: 1rem;
}
}
</style>
{% endblock %}

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.filter(aktiv=True))
return render(request, 'startseite.html', {"dokumente":standards,})
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

@@ -1,51 +1,190 @@
{% extends "base.html" %}
{% load mptt_tags %}
{% block title %}Referenz: {{ referenz.Path }}{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item"><a href="/referenzen/">Referenzen</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ referenz.Path }}</li>
{% endblock %}
{% block content %}
<h1><a href="../{{ referenz.ParentID }}"></a>{{ referenz.Path }}</h1>
{% if referenz.erklaerung %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-secondary text-light">
<h3 class="h5 m-0">Beschreibung</h3>
{% if referenz.url %}
<span class="badge bg-light text-black">
<a href="{{ referenz.url }}">Link</a>
</span>{% endif %}
</div>
<div class="card-body p-2">
<div class="row">
<div class="col-lg-8">
<!-- Referenz Header -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<div class="d-flex justify-content-between align-items-start">
<div>
<h1 class="h3 mb-2">🔗 {{ referenz.Path }}</h1>
{% if referenz.ParentID %}
<small class="opacity-75">
<a href="/referenzen/{{ referenz.ParentID }}/" class="text-white">
← Zurück zu übergeordneter Referenz
</a>
</small>
{% endif %}
</div>
{% if referenz.url %}
<a href="{{ referenz.url }}" class="btn btn-light btn-sm" target="_blank">
🔗 Externer Link
</a>
{% endif %}
</div>
</div>
{% if referenz.erklaerung %}
<div class="card-body">
<h5 class="card-title">📖 Beschreibung</h5>
{% for typ, html in referenz.erklaerung %}
{% if html %}<div>{{ html|safe }}</div>{% endif %}{% endfor %}
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-secondary text-light">
<h3 class="h5 m-0">Referenzierte Vorgaben</h3>
{% if html %}
<div class="content-section">{{ html|safe }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="card-body p-2">
{% recursetree referenz.children %}
{% if not node == referenz %}
{#<a href="../{{node.id}}">#}
{{ node.Path }}
{#</a>#}
{% else %}
{{ node.Path }}
{% endif %}
<br>
{% if node.referenziertvon %}
<ul>
<!-- Referenzierte Vorgaben -->
<div class="card">
<div class="card-header">
<h3 class="h5 mb-0">📝 Referenzierte Vorgaben</h3>
</div>
<div class="card-body">
{% recursetree referenz.children %}
{% if not node == referenz %}
<div class="mb-3 p-3 border rounded">
<h6 class="mb-2">{{ node.Path }}</h6>
{% if node.referenziertvon %}
<div class="ms-3">
<small class="text-muted">Referenziert von:</small>
<ul class="list-unstyled mb-0">
{% for ref in node.referenziertvon %}
<li class="mb-1">
<a href="/dokumente/{{ ref.dokument.nummer }}/#{{ref.Vorgabennummer}}"
class="text-decoration-none">
<span class="badge bg-secondary me-2">{{ ref.Vorgabennummer }}</span>
{{ ref.titel }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
{% if node.referenziertvon %}
<div class="ms-3 mb-3">
<small class="text-muted">Referenziert von:</small>
<ul class="list-unstyled mb-0">
{% for ref in node.referenziertvon %}
<li><a href="{% url 'standard_detail' nummer=ref.dokument.nummer %}#{{ref.Vorgabennummer}}">{{ref}}</a></li>
<li class="mb-1">
<a href="/dokumente/{{ ref.dokument.nummer }}/#{{ref.Vorgabennummer}}"
class="text-decoration-none">
<span class="badge bg-secondary me-2">{{ ref.Vorgabennummer }}</span>
{{ ref.titel }}
</a>
</li>
{% endfor %}
</ul>
<br>
{% endif %}
{% if not node.is_leaf_node %}
{{ children }}
{% endif %}
{% endrecursetree %}
</ul>
</div>
{% endif %}
{% if not node.is_leaf_node %}
<div class="ms-3">
{{ children }}
</div>
{% endif %}
{% endrecursetree %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Quick Actions -->
<div class="card mb-4 sticky-top" style="top: 1rem;">
<div class="card-header">
<h5 class="mb-0">⚡ Aktionen</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
{% if referenz.ParentID %}
<a href="/referenzen/{{ referenz.ParentID }}/" class="btn btn-outline btn-sm">
← Zurück
</a>
{% endif %}
<a href="/referenzen/" class="btn btn-outline btn-sm">
🌳 Zur Baumansicht
</a>
{% if referenz.url %}
<a href="{{ referenz.url }}" class="btn btn-outline btn-sm" target="_blank">
🔗 Externer Link
</a>
{% endif %}
</div>
</div>
</div>
<!-- Statistics -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">📊 Informationen</h5>
</div>
<div class="card-body">
<div class="mb-3">
<small class="text-muted">Referenz-ID</small>
<p class="mb-0 fw-bold">{{ referenz.id }}</p>
</div>
{% if referenz.ParentID %}
<div class="mb-3">
<small class="text-muted">Übergeordnete Referenz</small>
<p class="mb-0">
<a href="/referenzen/{{ referenz.ParentID }}/" class="text-decoration-none">
ID: {{ referenz.ParentID }}
</a>
</p>
</div>
{% endif %}
<div class="mb-3">
<small class="text-muted">Anzahl Unterelemente</small>
<p class="mb-0 fw-bold">{{ referenz.get_children.count }}</p>
</div>
</div>
</div>
<!-- Navigation Help -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">💡 Hinweise</h5>
</div>
<div class="card-body">
<ul class="small mb-0">
<li>Diese Seite zeigt Details zur ausgewählten Referenz</li>
<li>Verknüpfte Vorgaben sind direkt verlinkt</li>
<li>Nutzen Sie die Baumansicht für die Übersicht</li>
</ul>
</div>
</div>
</div>
</div>
<style>
.content-section {
line-height: 1.6;
margin-bottom: 1rem;
}
.content-section h1, .content-section h2, .content-section h3 {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.border {
border-color: var(--border-color) !important;
}
.badge {
font-size: 0.75rem;
}
</style>
{% endblock %}

View File

@@ -1,21 +1,466 @@
{% extends "base.html" %}
{% block title %}Referenzen{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item active" aria-current="page">Referenzen</li>
{% endblock %}
{% block content %}
<h1>Referenzen</h1>
<div class="row">
<div class="col-lg-8">
<!-- Header -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3 mb-0">🔗 Referenzbaum</h1>
<div class="d-flex gap-2">
<button class="btn btn-outline btn-sm" onclick="expandAll()">
Alle ausklappen
</button>
<button class="btn btn-outline btn-sm" onclick="collapseAll()">
Alle einklappen
</button>
</div>
</div>
</div>
<div class="card-body">
<p class="text-muted mb-0">
Hier finden Sie alle Referenzen und Querverbindungen zwischen den Standards und Vorgaben.
Klicken Sie auf die Pfeile um den Baum zu navigieren.
</p>
</div>
</div>
<div>
{% load mptt_tags %}
<ul class="tree">
{% recursetree referenzen %}
<li>
<a href="{{node.id}}">{{ node.name_nummer }}{% if node.name_text %} ({{node.name_text}}){% endif %}</a>
{% if not node.is_leaf_node %}
<ul class="children">
<!-- Search and Filter -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-8">
<label for="tree-search" class="form-label">🔍 Referenzen durchsuchen</label>
<input type="text"
class="form-control"
id="tree-search"
placeholder="Suchbegriff eingeben..."
onkeyup="filterTree()">
</div>
<div class="col-md-4">
<label for="tree-filter" class="form-label">🏷️ Filter</label>
<select class="form-select" id="tree-filter" onchange="filterTree()">
<option value="">Alle anzeigen</option>
<option value="has-children">Mit Unterelementen</option>
<option value="leaf-only">Nur Endpunkte</option>
</select>
</div>
</div>
</div>
</div>
<!-- Interactive Tree -->
<div class="card">
<div class="card-body">
{% load mptt_tags %}
<div id="tree-container">
<ul class="tree-root">
{% recursetree referenzen %}
<li class="tree-node" data-node-id="{{ node.id }}" data-node-text="{{ node.name_text|default:'' }} {{ node.name_nummer|default:'' }}">
<div class="tree-node-content" onclick="toggleNode(this)">
{% if not node.is_leaf_node %}
<span class="tree-toggle"></span>
{% else %}
<span class="tree-toggle-placeholder"></span>
{% endif %}
<a href="{{ node.id }}" class="tree-link">
{% if node.name_nummer %}
<span class="tree-number">{{ node.name_nummer }}</span>
{% endif %}
{% if node.name_text %}
<span class="tree-text">{{ node.name_text }}</span>
{% endif %}
</a>
<div class="tree-node-meta">
{% if not node.is_leaf_node %}
<span class="badge bg-info">{{ node.get_children.count }} Unterelemente</span>
{% else %}
<span class="badge bg-secondary">Endpunkt</span>
{% endif %}
</div>
</div>
{% if not node.is_leaf_node %}
<ul class="tree-children">
{{ children }}
</ul>
{% endif %}
</li>
{% endrecursetree %}
</ul>
</ul>
{% endif %}
</li>
{% endrecursetree %}
</ul>
</div>
{% if not referenzen %}
<div class="text-center py-5">
<span style="font-size: 3rem;">🔗</span>
<h4 class="text-muted mt-3">Keine Referenzen gefunden</h4>
<p class="text-muted">Es wurden keine Referenzen in der Datenbank gefunden.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Statistics -->
<div class="card mb-4 sticky-top" style="top: 1rem;">
<div class="card-header">
<h5 class="mb-0">📊 Statistiken</h5>
</div>
<div class="card-body">
<div class="row text-center mb-3">
<div class="col-6">
<h4 class="text-primary mb-1">{{ referenzen|length }}</h4>
<small class="text-muted">Gesamt</small>
</div>
<div class="col-6">
<h4 class="text-success mb-1">
{% for node in referenzen %}
{% if node.is_leaf_node %}
{% if forloop.first %}1{% else %}{{ forloop.counter }}{% endif %}
{% endif %}
{% endfor %}
</h4>
<small class="text-muted">Endpunkte</small>
</div>
</div>
<div class="border-top pt-3">
<h6 class="text-muted mb-2">Baumtiefe</h6>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-primary" style="width: 75%"></div>
</div>
<small class="text-muted">Maximale Tiefe: 4 Ebenen</small>
</div>
</div>
</div>
<!-- Quick Navigation -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">🧭 Navigation</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button class="btn btn-outline btn-sm" onclick="scrollToTop()">
⬆️ Zum Anfang
</button>
<button class="btn btn-outline btn-sm" onclick="findNextMatch()">
⬇️ Nächster Treffer
</button>
<button class="btn btn-outline btn-sm" onclick="resetFilters()">
🔄 Filter zurücksetzen
</button>
</div>
</div>
</div>
<!-- Help -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">💡 Hinweise</h5>
</div>
<div class="card-body">
<ul class="small mb-0">
<li>Klicken Sie auf ▼/▶ um Knoten ein-/auszuklappen</li>
<li>Nutzen Sie die Suche um gezielt zu filtern</li>
<li>Referenznummern sind hervorgehoben</li>
<li>Endpunkte haben keine Unterelemente</li>
</ul>
</div>
</div>
</div>
</div>
<style>
/* Tree Styles */
.tree-root {
list-style: none;
padding-left: 0;
margin: 0;
}
.tree-node {
margin-bottom: 2px;
}
.tree-node-content {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.tree-node-content:hover {
background-color: var(--bg-secondary);
border-color: var(--primary-color);
}
.tree-toggle {
width: 20px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
margin-right: 8px;
}
.tree-toggle-placeholder {
width: 20px;
margin-right: 8px;
}
.tree-link {
text-decoration: none;
color: var(--text-primary);
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.tree-link:hover {
color: var(--primary-color);
}
.tree-number {
font-family: var(--font-mono);
font-weight: 600;
color: var(--primary-color);
background-color: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.875rem;
}
.tree-text {
font-weight: 500;
}
.tree-node-meta {
margin-left: auto;
display: flex;
gap: 4px;
}
.tree-children {
list-style: none;
padding-left: 28px;
margin: 2px 0 0 0;
border-left: 2px solid var(--border-color);
margin-left: 10px;
}
.tree-children .tree-node {
position: relative;
}
.tree-children .tree-node::before {
content: '';
position: absolute;
left: -30px;
top: 20px;
width: 20px;
height: 1px;
background-color: var(--border-color);
}
/* Collapsed state */
.tree-children.collapsed {
display: none;
}
.tree-node.collapsed .tree-toggle {
transform: rotate(-90deg);
}
/* Highlighted search results */
.tree-node.highlighted > .tree-node-content {
background-color: var(--warning-color);
color: white;
}
.tree-node.highlighted .tree-number {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.tree-children {
padding-left: 20px;
}
.tree-node-meta {
display: none;
}
.tree-number {
font-size: 0.75rem;
}
}
</style>
<script>
let currentMatchIndex = -1;
let matches = [];
function toggleNode(element) {
const node = element.parentElement;
const children = node.querySelector(':scope > .tree-children');
const toggle = element.querySelector('.tree-toggle');
if (children) {
children.classList.toggle('collapsed');
node.classList.toggle('collapsed');
if (toggle) {
toggle.textContent = children.classList.contains('collapsed') ? '▶' : '▼';
}
}
}
function expandAll() {
const children = document.querySelectorAll('.tree-children');
const nodes = document.querySelectorAll('.tree-node');
const toggles = document.querySelectorAll('.tree-toggle');
children.forEach(child => child.classList.remove('collapsed'));
nodes.forEach(node => node.classList.remove('collapsed'));
toggles.forEach(toggle => {
if (toggle.textContent === '▶') {
toggle.textContent = '▼';
}
});
}
function collapseAll() {
const children = document.querySelectorAll('.tree-children');
const nodes = document.querySelectorAll('.tree-node');
const toggles = document.querySelectorAll('.tree-toggle');
children.forEach(child => child.classList.add('collapsed'));
nodes.forEach(node => node.classList.add('collapsed'));
toggles.forEach(toggle => {
if (toggle.textContent === '▼') {
toggle.textContent = '▶';
}
});
}
function filterTree() {
const searchTerm = document.getElementById('tree-search').value.toLowerCase();
const filterType = document.getElementById('tree-filter').value;
const nodes = document.querySelectorAll('.tree-node');
matches = [];
currentMatchIndex = -1;
nodes.forEach(node => {
const nodeText = node.dataset.nodeText.toLowerCase();
const hasChildren = node.querySelector(':scope > .tree-children') !== null;
const isLeaf = !hasChildren;
let showNode = true;
// Apply search filter
if (searchTerm && !nodeText.includes(searchTerm)) {
showNode = false;
}
// Apply type filter
if (filterType === 'has-children' && !hasChildren) {
showNode = false;
} else if (filterType === 'leaf-only' && !isLeaf) {
showNode = false;
}
// Show/hide node
node.style.display = showNode ? 'block' : 'none';
// Highlight search matches
if (searchTerm && nodeText.includes(searchTerm)) {
node.classList.add('highlighted');
matches.push(node);
} else {
node.classList.remove('highlighted');
}
// Auto-expand parent nodes of matches
if (searchTerm && nodeText.includes(searchTerm)) {
let parent = node.parentElement;
while (parent && parent.classList.contains('tree-children')) {
parent.classList.remove('collapsed');
parent.parentElement.classList.remove('collapsed');
const toggle = parent.parentElement.querySelector('.tree-toggle');
if (toggle) toggle.textContent = '▼';
parent = parent.parentElement.parentElement;
}
}
});
}
function findNextMatch() {
if (matches.length === 0) return;
currentMatchIndex = (currentMatchIndex + 1) % matches.length;
const match = matches[currentMatchIndex];
// Scroll to match
match.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight temporarily
match.style.backgroundColor = 'var(--accent-color)';
setTimeout(() => {
match.style.backgroundColor = '';
}, 1000);
}
function scrollToTop() {
document.getElementById('tree-container').scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
function resetFilters() {
document.getElementById('tree-search').value = '';
document.getElementById('tree-filter').value = '';
filterTree();
expandAll();
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Add keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.ctrlKey || e.metaKey) {
switch(e.key) {
case 'f':
e.preventDefault();
document.getElementById('tree-search').focus();
break;
case 'e':
e.preventDefault();
expandAll();
break;
case 'w':
e.preventDefault();
collapseAll();
break;
}
}
});
});
</script>
{% endblock %}

View File

@@ -5,7 +5,6 @@
border-radius: 8px;
padding: 15px;
margin-bottom: 50px;
background-color: #f9f9f9;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
@@ -38,4 +37,4 @@ tbody.djn-dynamic-form-dokumente-vorgabe td.original p {
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

@@ -0,0 +1,819 @@
/* Vorgaben UI Custom Styles */
:root {
/* Professional color scheme for security standards */
--primary-color: #1e3a8a;
--primary-dark: #1e2f5a;
--primary-light: #3b82f6;
--secondary-color: #64748b;
--accent-color: #0ea5e9;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--info-color: #06b6d4;
/* Neutral colors */
--gray-50: #f8fafc;
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
--gray-400: #94a3b8;
--gray-500: #64748b;
--gray-600: #475569;
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
/* Typography */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Transitions */
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
}
/* Dark mode variables */
[data-theme="dark"] {
--bg-primary: var(--gray-900);
--bg-secondary: var(--gray-800);
--bg-tertiary: var(--gray-700);
--text-primary: var(--gray-100);
--text-secondary: var(--gray-300);
--text-muted: var(--gray-400);
--border-color: var(--gray-700);
--card-bg: var(--gray-800);
--navbar-bg: var(--gray-900);
}
/* Light mode variables */
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: var(--gray-50);
--bg-tertiary: var(--gray-100);
--text-primary: var(--gray-900);
--text-secondary: var(--gray-700);
--text-muted: var(--gray-500);
--border-color: var(--gray-200);
--card-bg: #ffffff;
--navbar-bg: #ffffff;
}
/* Base styles */
* {
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color var(--transition-normal), color var(--transition-normal);
line-height: 1.6;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.3;
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
h1 { font-size: 2.25rem; }
h2 { font-size: 1.875rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
h5 { font-size: 1.125rem; }
h6 { font-size: 1rem; }
/* Navbar enhancements */
.navbar {
background-color: var(--navbar-bg) !important;
border-bottom: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
padding: var(--spacing-md) 0;
transition: all var(--transition-normal);
}
.navbar-brand {
font-weight: 700;
font-size: 1.5rem;
color: var(--primary-color) !important;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.navbar-brand::before {
content: "🔒";
font-size: 1.25rem;
}
.navbar-nav .nav-link {
color: var(--text-secondary) !important;
font-weight: 500;
padding: var(--spacing-sm) var(--spacing-md) !important;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
margin: 0 var(--spacing-xs);
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link.active {
color: var(--primary-color) !important;
background-color: var(--bg-secondary);
}
/* Search bar in navbar */
.navbar-search {
position: relative;
max-width: 400px;
flex: 1;
}
.navbar-search input {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-sm) var(--spacing-lg);
padding-right: 2.5rem;
width: 100%;
transition: all var(--transition-fast);
}
.navbar-search input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
}
.navbar-search button {
position: absolute;
right: var(--spacing-xs);
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
padding: var(--spacing-sm);
}
/* Dark mode toggle */
.theme-toggle {
background: none;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition-fast);
font-size: 1.25rem;
}
.theme-toggle:hover {
background-color: var(--bg-secondary);
color: var(--primary-color);
}
/* Breadcrumb */
.breadcrumb {
background-color: transparent;
padding: var(--spacing-md) 0;
margin-bottom: var(--spacing-lg);
}
.breadcrumb-item + .breadcrumb-item::before {
content: "";
color: var(--text-muted);
}
.breadcrumb-item a {
color: var(--text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.breadcrumb-item a:hover {
color: var(--primary-color);
}
/* Card enhancements */
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: all var(--transition-normal);
overflow: hidden;
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.card-header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
font-weight: 600;
padding: var(--spacing-lg);
}
.card-body {
padding: var(--spacing-lg);
}
/* Standard cards */
.standard-card {
border-left: 4px solid var(--primary-color);
margin-bottom: var(--spacing-lg);
}
.standard-card.inactive {
border-left-color: var(--gray-400);
opacity: 0.7;
}
.standard-card .card-title {
color: var(--text-primary);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: var(--spacing-sm);
}
.standard-card .standard-number {
color: var(--primary-color);
font-weight: 700;
font-family: var(--font-mono);
}
.standard-card .standard-meta {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: var(--spacing-md);
}
/* Badges */
.badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
}
.badge-relevance {
background-color: var(--info-color);
color: white;
}
.badge-status-active {
background-color: var(--success-color);
color: white;
}
.badge-status-inactive {
background-color: var(--gray-400);
color: white;
}
/* Buttons */
.btn {
font-weight: 500;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
border: none;
cursor: pointer;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-outline {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-outline:hover {
background-color: var(--bg-secondary);
border-color: var(--primary-color);
color: var(--primary-color);
}
/* Search enhancements */
.search-container {
max-width: 600px;
margin: 0 auto;
}
.search-form {
background-color: var(--card-bg);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
}
.search-results {
margin-top: var(--spacing-xl);
}
.result-item {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-md);
transition: all var(--transition-fast);
}
.result-item:hover {
box-shadow: var(--shadow-sm);
border-color: var(--primary-color);
}
/* Table of contents */
.toc {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
position: sticky;
top: var(--spacing-lg);
}
.toc h3 {
margin-top: 0;
margin-bottom: var(--spacing-md);
font-size: 1.125rem;
color: var(--text-primary);
}
.toc ul {
list-style: none;
padding-left: 0;
margin: 0;
}
.toc li {
margin-bottom: var(--spacing-xs);
}
.toc a {
color: var(--text-secondary);
text-decoration: none;
display: block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.toc a:hover,
.toc a.active {
background-color: var(--bg-tertiary);
color: var(--primary-color);
}
/* Footer */
.footer {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: var(--spacing-xl) 0;
margin-top: var(--spacing-2xl);
color: var(--text-muted);
text-align: center;
}
/* Responsive design */
@media (max-width: 1200px) {
.container-fluid {
padding-left: var(--spacing-md);
padding-right: var(--spacing-md);
}
}
@media (max-width: 992px) {
.navbar-search {
max-width: 300px;
}
.standard-card {
margin-bottom: var(--spacing-md);
}
.toc {
position: static;
margin-bottom: var(--spacing-lg);
}
}
@media (max-width: 768px) {
/* Navigation */
.navbar {
padding: var(--spacing-sm) 0;
}
.navbar-brand {
font-size: 1.25rem;
}
.navbar-search {
max-width: 100%;
margin-top: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.navbar-nav .nav-link {
padding: var(--spacing-sm) var(--spacing-md) !important;
margin: 0;
}
/* Typography */
h1 { font-size: 1.875rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
/* Cards */
.card {
margin-bottom: var(--spacing-md);
}
.standard-card {
border-left-width: 3px;
}
.standard-card .card-body {
padding: var(--spacing-md);
}
/* Buttons */
.btn {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.875rem;
}
.btn-lg {
padding: var(--spacing-md) var(--spacing-lg);
font-size: 1rem;
}
/* Forms */
.form-control, .form-select {
font-size: 0.875rem;
}
/* Search */
.search-form {
padding: var(--spacing-md);
}
.result-item {
padding: var(--spacing-md);
}
/* Table of Contents */
.toc {
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.toc ul {
font-size: 0.875rem;
}
/* Standard Detail Page */
.vorgabe-card .card-header {
padding: var(--spacing-md);
font-size: 0.875rem;
}
.vorgabe-card .card-body {
padding: var(--spacing-md);
}
.vorgabe-content {
font-size: 0.875rem;
}
/* Homepage */
.display-4 {
font-size: 2rem;
}
.statistics-card .h2 {
font-size: 1.5rem;
}
/* References Tree */
.tree-node-content {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.875rem;
}
.tree-children {
padding-left: var(--spacing-lg);
}
.tree-node-meta {
display: none;
}
/* Breadcrumb */
.breadcrumb {
padding: var(--spacing-sm) 0;
font-size: 0.875rem;
}
/* Footer */
.footer {
padding: var(--spacing-lg) 0;
font-size: 0.875rem;
}
}
@media (max-width: 576px) {
/* Extra small screens */
.container-fluid {
padding-left: var(--spacing-sm);
padding-right: var(--spacing-sm);
}
/* Typography */
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
h3 { font-size: 1.125rem; }
/* Cards */
.card {
border-radius: var(--radius-md);
}
.card-header {
padding: var(--spacing-sm) var(--spacing-md);
}
.card-body {
padding: var(--spacing-sm) var(--spacing-md);
}
/* Buttons */
.btn {
width: 100%;
margin-bottom: var(--spacing-xs);
}
.btn-group .btn {
width: auto;
}
/* Forms */
.row.g-3 > .col {
margin-bottom: var(--spacing-md);
}
/* Homepage */
.display-4 {
font-size: 1.75rem;
}
.hero-section .btn-lg {
width: 100%;
margin-bottom: var(--spacing-sm);
}
.statistics-row .col-6 {
margin-bottom: var(--spacing-md);
}
/* Search */
.search-page .row {
flex-direction: column;
}
.search-page .col-lg-4 {
order: 2;
}
.search-page .col-lg-8 {
order: 1;
}
/* Standard Detail */
.standard-detail-page .row {
flex-direction: column;
}
.standard-detail-page .col-lg-8 {
order: 1;
}
.standard-detail-page .col-lg-4 {
order: 2;
}
/* References */
.references-page .row {
flex-direction: column;
}
.references-page .col-lg-8 {
order: 1;
}
.references-page .col-lg-4 {
order: 2;
}
/* Tree navigation */
.tree-node-content {
flex-wrap: wrap;
}
.tree-link {
min-width: 0;
flex: 1;
}
/* Badges */
.badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
}
/* Lists */
.list-group-item {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.875rem;
}
/* Tables */
.table {
font-size: 0.75rem;
}
.table th,
.table td {
padding: var(--spacing-xs) var(--spacing-sm);
}
}
/* Touch-friendly adjustments for mobile */
@media (hover: none) and (pointer: coarse) {
.card:hover {
transform: none;
}
.btn:hover {
transform: none;
}
.tree-node-content:hover {
background-color: transparent;
border-color: transparent;
}
.navbar-nav .nav-link:hover {
background-color: transparent;
}
/* Increase touch targets */
.btn {
min-height: 44px;
min-width: 44px;
}
.tree-node-content {
min-height: 44px;
}
.navbar-nav .nav-link {
min-height: 44px;
display: flex;
align-items: center;
}
}
/* Landscape mobile optimizations */
@media (max-width: 768px) and (orientation: landscape) {
.navbar {
padding: var(--spacing-xs) 0;
}
.navbar-brand {
font-size: 1.125rem;
}
h1 { font-size: 1.625rem; }
.card {
margin-bottom: var(--spacing-sm);
}
.search-form {
padding: var(--spacing-sm) var(--spacing-md);
}
}
/* High DPI displays */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.navbar-brand::before {
image-rendering: -webkit-optimize-contrast;
}
.tree-toggle {
font-weight: 300;
}
}
/* Reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.card:hover {
transform: none;
}
.btn:hover {
transform: none;
}
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
}
.spinner {
border: 2px solid var(--border-color);
border-top: 2px solid var(--primary-color);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Print styles */
@media print {
.navbar,
.theme-toggle,
.btn,
.toc {
display: none !important;
}
body {
background: white !important;
color: black !important;
}
.card {
border: 1px solid #ccc !important;
box-shadow: none !important;
break-inside: avoid;
}
}

View File

@@ -1,30 +1,175 @@
{% extends "base.html" %}
{% block title %}Stichwort: {{stichwort.stichwort}}{% endblock %}
{% block title %}Stichwort: {{ stichwort.stichwort }}{% endblock %}
{% block breadcrumb_items %}
<li class="breadcrumb-item"><a href="/stichworte/">Stichworte</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ stichwort.stichwort }}</li>
{% endblock %}
{% block content %}
<h1>{{stichwort}}</h1>
{% if stichwort.erklaerung %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-secondary text-light">
<h3 class="h5 m-0">Beschreibung</h3>
</div>
<div class="card-body p-2">
<div class="row">
<div class="col-lg-8">
<!-- Stichwort Header -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h1 class="h3 mb-0">🏷️ {{ stichwort.stichwort }}</h1>
</div>
{% if stichwort.erklaerung %}
<div class="card-body">
<h5 class="card-title">📖 Beschreibung</h5>
{% for typ, html in stichwort.erklaerung %}
{% if html %}<div>{{ html|safe }}</div>{% endif %}{% endfor %}
{% if html %}
<div class="content-section">{{ html|safe }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<!-- Relevante Vorgaben -->
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h3 class="h5 mb-0">📝 Relevante Vorgaben</h3>
<span class="badge bg-success">
{% for vorgabe in stichwort.vorgaben %}
{% if vorgabe.get_status == "active" %}
{% if forloop.first %}1{% else %}{{ forloop.counter }}{% endif %}
{% endif %}
{% endfor %}
Aktiv
</span>
</div>
</div>
<div class="card-body">
{% if stichwort.vorgaben %}
<div class="list-group">
{% for vorgabe in stichwort.vorgaben %}
{% if vorgabe.get_status == "active" %}
<div class="list-group-item">
<div class="d-flex align-items-start">
<span class="badge bg-secondary me-3">{{ vorgabe.Vorgabennummer }}</span>
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="/dokumente/{{ vorgabe.dokument.nummer }}/#{{vorgabe.Vorgabennummer}}"
class="text-decoration-none">
{{ vorgabe.titel }}
</a>
</h6>
<small class="text-muted">
Standard: {{ vorgabe.dokument.nummer }} {{ vorgabe.dokument.name }}
</small>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="text-center py-4">
<p class="text-muted mb-0">
Keine aktiven Vorgaben für dieses Stichwort gefunden.
</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-secondary text-light">
<h3 class="h5 m-0">Relevante Vorgaben</h3>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Quick Actions -->
<div class="card mb-4 sticky-top" style="top: 1rem;">
<div class="card-header">
<h5 class="mb-0">⚡ Aktionen</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/stichworte/" class="btn btn-outline btn-sm">
← Zurück zur Liste
</a>
<a href="/search/?q={{ stichwort.stichwort }}" class="btn btn-outline btn-sm">
🔍 Nach "{{ stichwort.stichwort }}" suchen
</a>
</div>
</div>
</div>
<!-- Statistics -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">📊 Statistiken</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<h4 class="text-primary mb-1">
{% for vorgabe in stichwort.vorgaben %}
{% if vorgabe.get_status == "active" %}
{% if forloop.first %}1{% else %}{{ forloop.counter }}{% endif %}
{% endif %}
{% endfor %}
</h4>
<small class="text-muted">Aktive Vorgaben</small>
</div>
<div class="col-6">
<h4 class="text-info mb-1">
{{ stichwort.vorgaben|length }}
</h4>
<small class="text-muted">Gesamt</small>
</div>
</div>
</div>
</div>
<!-- Related Stichworte -->
{% if related_stichworte %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">🔗 Verwandte Stichworte</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2">
{% for related in related_stichworte %}
<a href="/stichworte/{{ related.stichwort }}/"
class="badge bg-light text-dark text-decoration-none">
{{ related.stichwort }}
</a>
{% endfor %}
</div>
</div>
</div>
<div class="card-body p-2">
<ul>
{% for vorgabe in stichwort.vorgaben %}
{% if vorgabe.get_status == "active" %}
<li><a href="{% url 'standard_detail' nummer=vorgabe.dokument.nummer %}#{{vorgabe.Vorgabennummer}}">{{vorgabe.Vorgabennummer}}</a>: {{vorgabe.titel}}</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
</div>
<style>
.content-section {
line-height: 1.6;
margin-bottom: 1rem;
}
.content-section h1, .content-section h2, .content-section h3 {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.list-group-item {
border: none;
border-bottom: 1px solid var(--border-color);
transition: all var(--transition-fast);
}
.list-group-item:hover {
background-color: var(--bg-secondary);
}
.list-group-item:last-child {
border-bottom: none;
}
.badge {
font-size: 0.75rem;
}
</style>
{% endblock %}

View File

@@ -1,14 +1,144 @@
{% extends "base.html" %}
{% block title %}Stichworte{% endblock %}
{% block content %}
<h1>Stichworte</h1>
{% block breadcrumb_items %}
<li class="breadcrumb-item active" aria-current="page">Stichworte</li>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>🏷️ Stichworte</h1>
<div class="d-flex gap-2">
<span class="badge bg-primary">{{ stichworte|length }} Kategorien</span>
</div>
</div>
<!-- Search and Filter -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-8">
<label for="stichwort-search" class="form-label">🔍 Stichworte durchsuchen</label>
<input type="text"
class="form-control"
id="stichwort-search"
placeholder="Stichwort eingeben..."
onkeyup="filterStichworte()">
</div>
<div class="col-md-4">
<label for="letter-filter" class="form-label">🔤 Buchstabe</label>
<select class="form-select" id="letter-filter" onchange="filterStichworte()">
<option value="">Alle Buchstaben</option>
{% for Anfang, Worte in stichworte.items %}
<option value="{{ Anfang }}">{{ Anfang }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<!-- Stichworte Grid -->
<div class="row" id="stichworte-container">
{% for Anfang, Worte in stichworte.items %}
<h2>{{ Anfang }}</h2>
<ul>
{% for Wort in Worte %}
<li><a href="{% url 'stichwort_detail' stichwort=Wort %}">{{ Wort }}</a></li>
{% endfor %}
</ul>
{% endfor %}
{% endblock %}
<div class="col-lg-4 col-md-6 mb-4 stichwort-category" data-letter="{{ Anfang }}">
<div class="card h-100">
<div class="card-header">
<h3 class="h5 mb-0">{{ Anfang }}</h3>
<span class="badge bg-secondary">{{ Worte|length }} Stichworte</span>
</div>
<div class="card-body">
<div class="list-group list-group-flush">
{% for Wort in Worte %}
<a href="/stichworte/{{ Wort }}/" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span>{{ Wort }}</span>
<span class="badge bg-light text-dark"></span>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- JavaScript for filtering -->
<script>
function filterStichworte() {
const searchTerm = document.getElementById('stichwort-search').value.toLowerCase();
const letterFilter = document.getElementById('letter-filter').value;
const categories = document.querySelectorAll('.stichwort-category');
categories.forEach(category => {
const letter = category.dataset.letter;
const items = category.querySelectorAll('.list-group-item');
let hasVisibleItems = false;
// Check if category matches letter filter
const matchesLetter = !letterFilter || letter === letterFilter;
// Filter items within category
items.forEach(item => {
const text = item.textContent.toLowerCase();
const matchesSearch = !searchTerm || text.includes(searchTerm);
if (matchesSearch && matchesLetter) {
item.style.display = 'flex';
hasVisibleItems = true;
} else {
item.style.display = 'none';
}
});
// Show/hide category based on visible items
category.style.display = (matchesLetter && hasVisibleItems) ? 'block' : 'none';
});
}
document.addEventListener('DOMContentLoaded', function() {
// Add hover effects
const items = document.querySelectorAll('.list-group-item');
items.forEach(item => {
item.addEventListener('mouseenter', function() {
this.style.backgroundColor = 'var(--bg-secondary)';
});
item.addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
});
});
});
</script>
<style>
.list-group-item {
border: none;
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-sm) var(--spacing-md);
transition: all var(--transition-fast);
}
.list-group-item:hover {
background-color: var(--bg-secondary);
transform: translateX(4px);
}
.list-group-item:last-child {
border-bottom: none;
}
.card-header {
display: flex;
justify-content: between;
align-items: center;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
@media (max-width: 768px) {
.list-group-item {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.875rem;
}
}
</style>
{% endblock %}

38
test_sanity_check.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python
"""
Simple script to test Vorgaben sanity checking
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'VorgabenUI.settings')
django.setup()
from dokumente.utils import check_vorgabe_conflicts, format_conflict_report
def main():
print("Running Vorgaben sanity check...")
print("=" * 50)
# Check for conflicts
conflicts = check_vorgabe_conflicts()
# Generate and display report
report = format_conflict_report(conflicts, verbose=True)
print(report)
print("=" * 50)
if conflicts:
print(f"\n⚠️ Found {len(conflicts)} conflicts that need attention!")
sys.exit(1)
else:
print("✅ All Vorgaben are valid!")
sys.exit(0)
if __name__ == "__main__":
main()