Compare commits

..

20 Commits

Author SHA1 Message Date
996584ef68 Some changes after code review; Deploying to Development
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 5s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 5s
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 10s
2026-01-20 10:17:29 +01:00
18fac6e8b9 Code reviewed; Package versions updated to latest (incl. Django 6)
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
2026-01-20 09:51:08 +01:00
492f8b4e90 NFS and kubernetes shenanigans
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 29s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 6s
SonarQube Scan / SonarQube Trigger (push) Failing after 1m34s
2026-01-19 23:42:47 +01:00
e86e3c19b5 NFS troubleshooting
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 1m28s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 7s
SonarQube Scan / SonarQube Trigger (push) Failing after 1m34s
2026-01-19 23:37:44 +01:00
938424a02e Kubernetes PVC changed to shared NFS
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
2026-01-19 22:19:31 +01:00
b9e1a06e09 Error pages in correct design
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 4s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 5s
SonarQube Scan / SonarQube Trigger (push) Failing after 49s
2026-01-19 13:52:07 +01:00
1a0c74bfa2 Static file serving out of DEBUG mode addressed
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 1m4s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 5s
SonarQube Scan / SonarQube Trigger (push) Failing after 55s
2026-01-19 13:26:26 +01:00
82455358ff Allowed IPs in configmap changed, again
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 47s
2026-01-15 17:07:15 +01:00
713798352d Allowed IPs in configmap changed
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 48s
2026-01-15 17:05:22 +01:00
0e8e2da169 Removed secret deployment from argocd
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 48s
2026-01-15 16:59:24 +01:00
e8f34f7fa5 Django options pulled out into configmap; Docker build should now succeed despite no ENV-var with secret
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 39s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 4s
SonarQube Scan / SonarQube Trigger (push) Failing after 47s
2026-01-15 16:34:56 +01:00
67d4087e3a Changed secret key deployment; Updated requirements due to vulnerability in urllib
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Failing after 1m3s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 7s
SonarQube Scan / SonarQube Trigger (push) Failing after 8s
2026-01-15 16:18:25 +01:00
ffda7ca601 SECRET_KEY now uses a kubernetes secret with a fallback value for local testing
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 2m9s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 9s
SonarQube Scan / SonarQube Trigger (push) Failing after 2m29s
2026-01-15 16:04:25 +01:00
9d0a838238 Deployment
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 22s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 4s
SonarQube Scan / SonarQube Trigger (push) Failing after 46s
2026-01-09 14:21:29 +01:00
b0725fb385 Refactored XML export code between management command and view
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 56s
2026-01-09 14:18:33 +01:00
c77e8c0432 XML export adjusted 2026-01-09 13:55:43 +01:00
51969141e7 Typo in Dockerfile fixed
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 1m5s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 4s
2026-01-07 14:49:26 +01:00
b7f50ce30f XML export added
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 1m5s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 4s
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Failing after 8s
2026-01-07 14:43:59 +01:00
d3d0298ad1 scripts added and Dockerfile adjusted to remove them from production 2026-01-07 14:31:54 +01:00
29c1ad1dcf Tests for document import added
All checks were successful
SonarQube Scan / SonarQube Trigger (push) Successful in 4m14s
2025-12-09 15:27:49 +01:00
51 changed files with 3617 additions and 431 deletions

22
.argocdignore Normal file
View File

@@ -0,0 +1,22 @@
# ArgoCD ignore patterns
# Exclude template files from ArgoCD deployment
# Secret templates (deployed separately by scripts)
templates/
**/secret.yaml
# Documentation and scripts
docs/
scripts/
*.md
README*
# Development files
.env*
.git*
.vscode/
.idea/
# CI/CD files
.gitea/
.github/

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ AGENT*.md
# Diagram cache directory
media/diagram_cache/
.env
data/db.sqlite3
data/
dataremote/

View File

@@ -1,4 +1,4 @@
FROM python:3.14 AS baustelle
FROM python:3.15-rc-trixie AS baustelle
RUN mkdir /app
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
@@ -7,12 +7,12 @@ RUN pip install --upgrade pip
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.14-slim
FROM python:3.15-rc-slim-trixie
RUN useradd -m -r appuser && \
mkdir /app && \
chown -R appuser /app
COPY --from=baustelle /usr/local/lib/python3.14/site-packages/ /usr/local/lib/python3.14/site-packages/
COPY --from=baustelle /usr/local/lib/python3.15/site-packages/ /usr/local/lib/python3.15/site-packages/
COPY --from=baustelle /usr/local/bin/ /usr/local/bin/
RUN rm /usr/bin/tar /usr/lib/x86_64-linux-gnu/libncur*
WORKDIR /app
@@ -21,6 +21,8 @@ ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
# Set build environment variable to enable fallback secret key during build
ENV DOCKER_BUILDKIT=1
RUN rm -rvf /app/Dockerfile* \
/app/README.md \
/app/argocd \
@@ -30,6 +32,7 @@ RUN rm -rvf /app/Dockerfile* \
/app/requirements.txt \
/app/node_modules \
/app/*.json \
/app/scripts \
/app/test_*.py && \
python3 /app/manage.py collectstatic --noinput
CMD ["gunicorn","--bind","0.0.0.0:8000","--workers","3","VorgabenUI.wsgi:application"]

View File

@@ -1,141 +0,0 @@
"""
Django settings for VorgabenUI project.
Generated by 'django-admin startproject' using Django 5.2.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = bool(os.environ.get("DEBUG", default=0))
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS","127.0.0.1").split(",")
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'dokumente',
'abschnitte',
'stichworte',
'mptt',
'nested_admin',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'VorgabenUI.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'VorgabenUI.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data/db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'de-ch'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT="/home/adebaumann/VorgabenUI/staticfiles/"
STATICFILES_DIRS= (
os.path.join(BASE_DIR,"static"),
)
# Media files (User-uploaded content)
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Diagram cache settings
DIAGRAM_CACHE_DIR = 'diagram_cache' # relative to MEDIA_ROOT
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DATA_UPLOAD_MAX_NUMBER_FIELDS=10250
NESTED_ADMIN_LAZY_INLINES = True

View File

@@ -20,13 +20,34 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '429ti9tugj9güLLO))(G&G94KF452R3Fieaek$&6s#zlao-ca!#)_@j6*u+8s&bvfil^qyo%&-sov$ysi'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes', 'on')
ALLOWED_HOSTS = ["10.128.128.144","localhost","127.0.0.1","*"]
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('VORGABENUI_SECRET')
if not SECRET_KEY:
# Check if we're in a build environment (Docker build, CI, etc.)
is_build_env = any([
os.environ.get('DOCKER_BUILDKIT'), # Docker build
os.environ.get('CI'), # CI environment
os.environ.get('GITHUB_ACTIONS'), # GitHub Actions
os.environ.get('GITEA_ACTIONS'), # Gitea Actions
])
# Use DEBUG environment variable or assume debug mode for local development
debug_mode = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes', 'on')
if debug_mode or is_build_env:
# Fixed fallback key for local development and build environments
SECRET_KEY = 'dev-fallback-key-for-local-debugging-only-not-for-production-use-12345'
if not is_build_env: # Don't log during build to avoid noise
import logging
logging.warning("🚨 Using fallback SECRET_KEY for local development. This should NEVER happen in production!")
else:
raise ValueError("VORGABENUI_SECRET environment variable is required")
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', "10.128.128.144,localhost,127.0.0.1,*").split(",")
# Application definition
@@ -37,6 +58,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'whitenoise',
'dokumente',
'abschnitte',
'stichworte',
@@ -48,6 +70,7 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
@@ -115,7 +138,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'de-ch'
TIME_ZONE = 'UTC'
TIME_ZONE = 'Europe/Zurich'
USE_I18N = True
@@ -143,6 +166,12 @@ DIAGRAM_CACHE_DIR = 'diagram_cache' # relative to MEDIA_ROOT
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Custom error pages
handler400 = 'pages.views.custom_400'
handler403 = 'pages.views.custom_403'
handler404 = 'pages.views.custom_404'
handler500 = 'pages.views.custom_500'
DATA_UPLOAD_MAX_NUMBER_FIELDS=10250
NESTED_ADMIN_LAZY_INLINES = True

View File

@@ -40,9 +40,7 @@ urlpatterns = [
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(template_name='registration/password_change_done.html'), name='password_change_done'),
]
# Serve static files
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Serve media files (including cached diagrams)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

25
argocd/configmap.yaml Normal file
View File

@@ -0,0 +1,25 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: django-config
namespace: vorgabenui
data:
# Django Configuration
DEBUG: "false"
DJANGO_ALLOWED_HOSTS: "vorgabenportal.knowyoursecurity.com,localhost,127.0.0.1,*"
DJANGO_SETTINGS_MODULE: "VorgabenUI.settings"
# Application Configuration
LANGUAGE_CODE: "de-ch"
TIME_ZONE: "UTC"
# Static and Media Configuration
STATIC_URL: "/static/"
MEDIA_URL: "/media/"
# Database Configuration (for future use)
# DATABASE_ENGINE: "django.db.backends.sqlite3"
# DATABASE_NAME: "/app/data/db.sqlite3"
# Security Configuration
# CSRF_TRUSTED_ORIGINS: "https://vorgabenportal.knowyoursecurity.com"

View File

@@ -14,19 +14,44 @@ spec:
app: django
spec:
securityContext:
fsGroup: 999
fsGroup: 99
fsGroupChangePolicy: "OnRootMismatch"
initContainers:
- name: loader
image: git.baumann.gr/adebaumann/vui-data-loader:0.11
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ]
command: [ "sh","-c","if [ ! -f /data/db.sqlite3 ] || [ ! -s /data/db.sqlite3 ]; then cp preload/preload.sqlite3 /data/db.sqlite3 && echo 'Database copied from preload'; else echo 'Existing database preserved'; fi" ]
volumeMounts:
- name: data
mountPath: /data
containers:
- name: web
image: git.baumann.gr/adebaumann/vui:0.973
image: git.baumann.gr/adebaumann/vui:0.983
imagePullPolicy: Always
securityContext:
runAsUser: 99
env:
# Secret configuration
- name: VORGABENUI_SECRET
valueFrom:
secretKeyRef:
name: vorgabenui-secrets
key: vorgabenui_secret
# ConfigMap configuration
- name: DEBUG
valueFrom:
configMapKeyRef:
name: django-config
key: DEBUG
- name: DJANGO_ALLOWED_HOSTS
valueFrom:
configMapKeyRef:
name: django-config
key: DJANGO_ALLOWED_HOSTS
- name: DJANGO_SETTINGS_MODULE
valueFrom:
configMapKeyRef:
name: django-config
key: DJANGO_SETTINGS_MODULE
ports:
- containerPort: 8000
volumeMounts:

View File

@@ -4,6 +4,9 @@ metadata:
name: django-data-pv
namespace: vorgabenui
spec:
claimRef:
name: django-data-pvc
namespace: vorgabenui
capacity:
storage: 2Gi
accessModes:

512
code-review.md Normal file
View File

@@ -0,0 +1,512 @@
# Code Review: vgui-cicd Django Project
**Date:** January 20, 2026
**Reviewer:** AI Code Review
**Project Path:** /home/adebaumann/development/vgui-cicd
---
## 1. PROJECT STRUCTURE
### Overview
The project follows Django conventions with a clear app structure:
- **VorgabenUI** - Main project settings, URLs, WSGI/ASGI
- **dokumente** - Core document and Vorgabe models (315 lines)
- **abschnitte** - Text section models and utilities
- **stichworte** - Keyword/stichwort models
- **referenzen** - Reference models (MPTT-based)
- **rollen** - Role models
- **pages** - General pages and views
- **diagramm_proxy** - Diagram caching functionality
### Issues Found
**Minor - settings-docker.py**: The `settings-docker.py` file has duplicate `AUTH_PASSWORD_VALIDATORS` definitions (lines 92-105 and 183-199), which is redundant.
- **File**: `/home/adebaumann/development/vgui-cicd/VorgabenUI/settings-docker.py` (lines 92-105 and 183-199)
---
## 2. SETTINGS REVIEW
### Critical Issues
**1. Fallback SECRET_KEY in production** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`, lines 27-47)
```python
SECRET_KEY = os.environ.get('VORGABENUI_SECRET')
if not SECRET_KEY:
is_build_env = any([...])
debug_mode = ...
if debug_mode or is_build_env:
SECRET_KEY = 'dev-fallback-key-for-local-debugging-only-not-for-production-use-12345'
```
- **Issue**: Even though there's a check for build environments, the hardcoded fallback key creates a significant security risk if the environment variable is not properly set in production
- **Recommendation**: The fallback should NEVER be enabled, even in development - require the environment variable to be set
**2. DEBUG mode default** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`, line 24)
```python
DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes', 'on')
```
- **Issue**: DEBUG defaults to True, which could expose sensitive information if environment variables are misconfigured
- **Recommendation**: Require explicit setting of DEBUG to False in production
### Major Issues
**3. ALLOWED_HOSTS with wildcard** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`, line 50)
```python
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', "10.128.128.144,localhost,127.0.0.1,*").split(",")
```
- **Issue**: Default includes `*` which allows any host - dangerous in production
- **Recommendation**: Default should not include wildcard; require explicit configuration
**4. No rate limiting on authentication** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`)
- **Issue**: No login throttling or rate limiting configured for authentication endpoints
- **Recommendation**: Add `django-axes` or similar for brute-force protection
**5. SQLite in production** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`, lines 109-114)
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data/db.sqlite3',
}
}
```
- **Issue**: SQLite is used as default database - not suitable for production with concurrent access
- **Recommendation**: Configure PostgreSQL or other production-ready database
### Minor Issues
**6. TIME_ZONE mismatch** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`, lines 139-145)
- **Issue**: `LANGUAGE_CODE = 'de-ch'` but `TIME_ZONE = 'UTC'` - timezone should probably be 'Europe/Zurich' for Swiss deployment
- **File**: `/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py` (lines 141)
---
## 3. MODELS REVIEW
### Major Issues
**1. Missing `verbose_name` on models** - Several models are missing proper `verbose_name` in their Meta class:
- `Stichwort` (`/home/adebaumann/development/vgui-cicd/stichworte/models.py`, lines 4-11) - missing verbose_name
- `Referenz` (`/home/adebaumann/development/vgui-cicd/referenzen/models.py`, lines 6-27) - missing verbose_name
- `Rolle` (`/home/adebaumann/development/vgui-cicd/rollen/models.py`, lines 5-11) - missing verbose_name
- `Person` (`/home/adebaumann/development/vgui-cicd/dokumente/models.py`, lines 22-30) - missing verbose_name
- `Thema` (`/home/adebaumann/development/vgui-cicd/dokumente/models.py`, lines 32-39) - missing verbose_name
**2. BooleanField with `blank=True` but no default** (`/home/adebaumann/development/vgui-cicd/dokumente/models.py`, line 52)
```python
aktiv = models.BooleanField(blank=True)
```
- **Issue**: BooleanField should have explicit `default=False` for clarity
- **Recommendation**: Add `default=False`
**3. No database constraints for date validation** (`/home/adebaumann/development/vgui-cicd/dokumente/models.py`, lines 96-97)
```python
gueltigkeit_von = models.DateField()
gueltigkeit_bis = models.DateField(blank=True,null=True)
```
- **Issue**: No database-level constraint ensuring `gueltigkeit_bis >= gueltigkeit_von`
- **Recommendation**: Add constraint validation or override `save()` method
### Minor Issues
**4. Inconsistent naming in foreign key fields** - Some models use plural related names inconsistently:
- `autoren` and `pruefende` on `Dokument` use plural (correct)
- Could consider singular `related_name` for consistency where applicable
**5. Missing `related_name` on some ManyToMany fields** (`/home/adebaumann/development/vgui-cicd/dokumente/models.py`, line 289)
```python
autoren = models.ManyToManyField(Person) # Missing related_name
```
- **Recommendation**: Add `related_name='changelog_entries'` for clarity
---
## 4. VIEWS REVIEW
### Critical Issues
**1. No CSRF protection on comment endpoints** (`/home/adebaumann/development/vgui-cicd/dokumente/views.py`, lines 321-377)
```python
@require_POST
@login_required
def add_vorgabe_comment(request, vorgabe_id):
# No @csrf_exempt but also no CSRF token verification in the view
```
- **Issue**: The view uses `@require_POST` but doesn't verify CSRF tokens for the JSON endpoint
- **Recommendation**: Add `@csrf_exempt` ONLY if intentionally bypassing, or ensure CSRF is handled via the X-CSRFToken header (which is done in the template)
**Note**: Looking at line 368, the template sends `'X-CSRFToken': getCookie('csrftoken')`, so this is actually properly handled. **Not a bug.**
**2. XSS in comment display** (`/home/adebaumann/development/vgui-cicd/dokumente/views.py`, lines 305-306)
```python
escaped_text = escape(comment.text).replace('\n', '<br>')
```
- **Issue**: Using `escape()` is good, but line breaks are converted to `<br>` which could still be exploited
- **Note**: The `dangerous_patterns` check at lines 339-343 provides some protection
- **Recommendation**: Consider using a more robust HTML sanitization library
**3. No input validation on comment length** (`/home/adebaumann/development/vgui-cicd/dokumente/views.py`, line 335)
```python
if len(text) > 2000: # Reasonable length limit
```
- **Issue**: This is actually properly implemented with a 2000 character limit
- **Status**: OK
### Major Issues
**4. Referenz view lacks error handling** (`/home/adebaumann/development/vgui-cicd/referenzen/views.py`, lines 11-19)
```python
def detail(request, refid):
referenz_item = Referenz.objects.get(id=refid)
```
- **Issue**: `DoesNotExist` exception not caught - will return 500 error instead of 404
- **Recommendation**: Use `get_object_or_404` for consistency
### Minor Issues
**5. Search view allows complex regex patterns** (`/home/adebaumann/development/vgui-cicd/pages/views.py`, lines 36-70)
- **Issue**: The validation is good but the `groupby` usage at line 54 could fail if data is not properly sorted
- **Recommendation**: Add explicit ordering before groupby
**6. No rate limiting on search** (`/home/adebaumann/development/vgui-cicd/pages/views.py`)
- **Issue**: Search endpoint could be abused for DoS
- **Recommendation**: Add rate limiting
---
## 5. URL CONFIGURATION
### Minor Issues
**1. No URL namespace for include** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/urls.py`, lines 31-35)
```python
path('dokumente/', include("dokumente.urls")),
path('stichworte/', include("stichworte.urls")),
```
- **Issue**: No `app_name` namespace defined in included apps
- **Recommendation**: Add `app_name = 'dokumente'` to `dokumente/urls.py` for cleaner reversals
**2. Inconsistent trailing slashes** - Most URLs have trailing slashes but not all - ensure consistency
---
## 6. TEMPLATES REVIEW
### Critical Issues
**1. XSS vulnerability in `standard_detail.html`** (`/home/adebaumann/development/vgui-cicd/dokumente/templates/standards/standard_detail.html`, lines 163-164)
```html
{% for ref in vorgabe.referenzpfade %}
{{ ref|safe }}{% if not forloop.last %}, {% endif %}
```
- **Issue**: Using `|safe` on reference paths could allow XSS if malicious content is stored
- **Recommendation**: Use `escape` filter and handle line breaks separately
**2. JavaScript in template without CSP** (`/home/adebaumann/development/vgui-cicd/dokumente/templates/standards/standard_detail.html`, lines 249-424)
- **Issue**: Inline JavaScript in template
- **Note**: CSP headers are set in the view (line 316-317), but inline scripts violate strict CSP
- **Recommendation**: Move JavaScript to external file
### Major Issues
**3. Missing ARIA labels and roles** - Several accessibility issues:
- Base template (`/home/adebaumann/development/vgui-cicd/pages/templates/base.html`) has navigation but missing `aria-label` on some elements
- The mobile navigation could use better ARIA attributes
**4. Missing alt attributes on images** (`/home/adebaumann/development/vgui-cicd/pages/templates/base.html`, lines 39-41)
```html
<img src="{% static 'swiss/img/logo-CH.svg' %}"
onerror="this.onerror=null; this.src='{% static 'swiss/img/logo-CH.png' %}'"
alt="Zur Startseite" />
```
- **Issue**: alt is present but could be more descriptive
- **Status**: Acceptable
### Minor Issues
**5. Hardcoded URLs in templates** (`/home/adebaumann/development/vgui-cicd/dokumente/templates/standards/incomplete_vorgaben.html`, line 21)
```html
<a href="/autorenumgebung/dokumente/vorgabe/{{ item.vorgabe.id }}/change/"
```
- **Issue**: Hardcoded admin URL instead of using URL reversal
- **Recommendation**: Use `{% url 'admin:dokumente_vorgabe_change' item.vorgabe.id %}`
---
## 7. FORMS REVIEW
### Minor Issues
**1. No dedicated form classes** - Most form handling is done via Django admin forms or directly in views
- **Recommendation**: Consider creating explicit `ModelForm` classes for views that accept user input (e.g., comment form)
**2. VorgabeForm in admin could have more validation** (`/home/adebaumann/development/vgui-cicd/dokumente/admin.py`, lines 95-107)
- **Issue**: The form only validates Thema is required
- **Recommendation**: Add validation for date ranges and conflicts
---
## 8. MANAGEMENT COMMANDS
### Major Issues
**1. import-document command lacks error recovery** (`/home/adebaumann/development/vgui-cicd/dokumente/management/commands/import-document.py`, lines 288-349)
```python
for v in vorgaben_data:
try:
thema = Thema.objects.get(name=v["thema"])
except Thema.DoesNotExist:
self.stdout.write(self.style.WARNING(...))
continue # Silently skips vorgabe
```
- **Issue**: If one Vorgabe fails, the entire command may leave partial data
- **Recommendation**: Use `transaction.atomic()` to ensure atomicity
**2. No progress indicator for large imports** (`/home/adebaumann/development/vgui-cicd/dokumente/management/commands/import-document.py`)
- **Issue**: For large files, no progress shown
- **Recommendation**: Add progress output
### Minor Issues
**3. export_json command hardcodes "Standard IT-Sicherheit"** (`/home/adebaumann/development/vgui-cicd/dokumente/management/commands/export_json.py`, line 30)
```python
result = {
"Vorgabendokument": {
"Typ": "Standard IT-Sicherheit",
```
- **Issue**: Typ is hardcoded instead of using `dokument.dokumententyp.name`
- **Recommendation**: Use actual dokumententyp
---
## 9. TESTS REVIEW
### Major Issues
**1. No tests for diagram caching** - The `diagramm_proxy` module has no test coverage
- **Recommendation**: Add tests for `diagram_cache.py`
**2. No tests for referenzen views** - The tree view and detail view have no test coverage
- **Recommendation**: Add tests for `referenzen/views.py`
**3. No tests for authentication security** - Missing tests for:
- Brute-force protection
- Session management
- Password policy enforcement
### Minor Issues
**4. Test file organization** - `test_json.py` should be part of `tests.py` or in a proper test package structure
- **Status**: Acceptable but could be improved
**5. Tests rely on hardcoded paths** (`/home/adebaumann/development/vgui-cicd/dokumente/tests.py`, lines 1165-1168)
```python
self.assertContains(response, 'href="/autorenumgebung/dokumente/vorgabe/2/change/"')
```
- **Issue**: Uses hardcoded URL paths instead of URL reversal
- **Recommendation**: Use `reverse('admin:dokumente_vorgabe_change', args=[vorgabe.pk])`
---
## 10. SECURITY REVIEW
### Critical Issues
**1. No rate limiting on any endpoint** - All views lack rate limiting
- **Recommendation**: Add `django-ratelimit` or similar
**2. Diagram cache potentially vulnerable to DoS** (`/home/adebaumann/development/vgui-cicd/diagramm_proxy/diagram_cache.py`, lines 24-67)
```python
def get_cached_diagram(diagram_type, diagram_content):
content_hash = compute_hash(diagram_content)
cache_path = get_cache_path(diagram_type, content_hash)
if default_storage.exists(cache_path):
return cache_path
# Generate diagram via POST request
url = f"{KROKI_UPSTREAM}/{diagram_type}/svg"
```
- **Issue**: No validation on diagram size or content - could lead to DoS via large diagrams
- **Recommendation**: Add size limits and timeout
### Major Issues
**3. CSRF trusted origins only for HTTPS** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`, line 104)
```python
CSRF_TRUSTED_ORIGINS=["https://vorgabenportal.knowyoursecurity.com"]
```
- **Issue**: Only one origin configured - ensure this covers all deployment URLs
- **Recommendation**: Make configurable via environment variable
**4. No session expiry configuration** (`/home/adebaumann/development/vgui-cicd/VorgabenUI/settings.py`)
- **Issue**: Sessions don't expire
- **Recommendation**: Set `SESSION_COOKIE_AGE` and `SESSION_SAVE_EVERY_REQUEST`
---
## 11. CODE STYLE COMPLIANCE (AGENTS.md)
### Violations Found
**1. Import order inconsistent** (`/home/adebaumann/development/vgui-cicd/dokumente/views.py`, lines 1-16)
- Imports are not strictly ordered (stdlib, Django, local apps)
- Example: `import parsedatetime` is placed after Django imports
**2. Missing German `verbose_name` on models** (as noted in Section 3)
**3. Function naming** (`/home/adebaumann/development/vgui-cicd/dokumente/models.py`, line 101)
```python
def Vorgabennummer(self): # Should be vorgabennummer (snake_case)
```
- **Issue**: Method name uses PascalCase instead of snake_case per AGENTS.md guidelines
**4. Typo in error message** (`/home/adebaumann/development/vgui-cicd/dokumente/models.py`, line 213)
```python
"Geltungsdauer übeschneidet sich" # Should be "überschneidet sich"
```
---
## 12. PERFORMANCE ISSUES
### Major Issues
**1. N+1 query potential in search** (`/home/adebaumann/development/vgui-cicd/pages/views.py`, lines 53-68)
```python
qs = VorgabeKurztext.objects.filter(inhalt__icontains=suchbegriff)...
```
- **Issue**: Uses `icontains` which cannot use database indexes effectively
- **Recommendation**: Consider PostgreSQL full-text search for better performance
**2. No select_related in referenzen views** (`/home/adebaumann/development/vgui-cicd/referenzen/views.py`, lines 7-8)
```python
def tree(request):
referenz_items = Referenz.objects.all()
```
- **Issue**: No prefetch_related for related data
- **Recommendation**: Add prefetch_related for `referenzerklaerung_set` and `unterreferenzen`
---
## SUMMARY
### Critical Issues (Must Fix)
| # | Issue | Location | Recommendation |
|---|-------|----------|----------------|
| 1 | SECRET_KEY fallback | `VorgabenUI/settings.py:27-47` | Never enable fallback, require env var |
| 2 | DEBUG defaults to True | `VorgabenUI/settings.py:24` | Require explicit False for production |
| 3 | No rate limiting | All views | Add django-ratelimit |
| 4 | Session never expires | `VorgabenUI/settings.py` | Set SESSION_COOKIE_AGE |
| 5 | XSS via `\|safe` filter | `standard_detail.html:163-164` | Use `escape` filter |
### Major Issues (Should Fix)
| # | Issue | Location | Recommendation |
|---|-------|----------|----------------|
| 1 | SQLite database | `VorgabenUI/settings.py:109-114` | Use PostgreSQL for production |
| 2 | ALLOWED_HOSTS wildcard | `VorgabenUI/settings.py:50` | Remove `*` from default |
| 3 | Missing date constraint | `dokumente/models.py:96-97` | Add validation for date ranges |
| 4 | Import not atomic | `import-document.py:288-349` | Wrap in transaction.atomic() |
| 5 | Missing test coverage | Multiple modules | Add tests for untested code |
### Minor Issues (Nice to Fix)
| # | Issue | Location |
|---|-------|----------|
| 1 | Code style violations | `dokumente/views.py:1-16` |
| 2 | Typo: "übeschneidet" | `dokumente/models.py:213` |
| 3 | Missing ARIA labels | `base.html` |
| 4 | Hardcoded URLs in templates | `incomplete_vorgaben.html:21` |
| 5 | Duplicate AUTH_PASSWORD_VALIDATORS | `settings-docker.py:92-105, 183-199` |
---
## RECOMMENDED ACTIONS
### Immediate (Before Production)
1. Set up proper SECRET_KEY via environment variable
2. Configure DEBUG=False explicitly
3. Remove wildcard from ALLOWED_HOSTS default
4. Add rate limiting to all endpoints
5. Configure session expiry
6. Fix XSS vulnerability in template
7. Switch to PostgreSQL database
### Short-term (1-2 Weeks)
1. Add database constraints for date validation
2. Wrap import command in transactions
3. Add missing verbose_name to models
4. Fix code style violations
5. Add test coverage for critical paths
6. Move inline JavaScript to external files
### Long-term (1 Month)
1. Implement PostgreSQL full-text search
2. Add comprehensive test suite
3. Set up CSP headers properly
4. Implement comprehensive authentication security
5. Add performance monitoring
6. Document all security configurations
---
## POSITIVE FINDINGS
1. **Good project organization** - Clear app structure following Django conventions
2. **Proper CSRF handling** - X-CSRFToken header properly implemented
3. **Input validation** - Comment length limits and dangerous pattern checks in place
4. **Good German localization** - German field names and verbose texts throughout
5. **Django admin integration** - Well-configured admin interface
6. **Management commands** - Useful import/export functionality
7. **Referenzen tree structure** - MPTT implementation for hierarchical data
---
## APPENDIX: Additional Issues Detected by LSP
The following issues were detected by the Language Server Protocol (LSP) analyzer:
### dokumente/models.py
| Line | Issue |
|------|-------|
| 14, 26, 36, 274 | `__str__` method return type mismatch - returns `CharField` instead of `str` |
| 70 | Unknown attribute `vorgaben` on `Dokument` |
| 102 | Unknown attribute `nummer` on `ForeignKey` |
| 106, 114 | Unknown attribute `strftime` on `DateField` |
| 137, 144, 196 | Unknown attribute `objects` on `type[Vorgabe]` |
| 173 | Unknown attribute `thema_id` on `Vorgabe` |
| 248, 254, 260, 266 | `Meta` class overrides incompatible parent class |
| 282 | `VorgabenTable.Meta` incompatible with `Vorgabe.Meta` |
| 294 | Unknown attribute `nummer` on `ForeignKey` |
| 314 | Unknown attributes `username` and `Vorgabennummer` on `ForeignKey` |
### dokumente/views.py
| Line | Issue |
|------|-------|
| 22, 133, 265 | Unknown attribute `objects` on `type[Dokument]` |
| 94 | Unknown attribute `objects` on `type[Vorgabe]` |
| 285 | Argument type `str` cannot be assigned to parameter `content` of type `bytes` |
| 345, 412, 440 | Unknown attribute `objects` on `type[VorgabeComment]` |
### abschnitte/models.py
| Line | Issue |
|------|-------|
| 6 | `__str__` method return type mismatch |
| 18 | Argument type `Literal[0]` cannot be assigned to parameter `default` |
### referenzen/models.py
| Line | Issue |
|------|-------|
| 23 | `__str__` method return type mismatch |
| 26 | `Referenz.Meta` overrides incompatible parent `MPTTModel.Meta` |
| 32 | `Referenzerklaerung.Meta` overrides incompatible parent `Textabschnitt.Meta` |
### rollen/models.py
| Line | Issue |
|------|-------|
| 8 | `__str__` method return type mismatch |
| 15 | `RollenBeschreibung.Meta` overrides incompatible parent `Textabschnitt.Meta` |
---
*End of Code Review*

383
docs/kubernetes-secrets.md Normal file
View File

@@ -0,0 +1,383 @@
# Kubernetes Configuration Management for VorgabenUI Django
This document describes how to manage Django configuration using Kubernetes secrets and ConfigMaps.
## Overview
Django configuration has been moved to Kubernetes-native resources for improved security and flexibility:
### **Secrets** (for sensitive data)
- `VORGABENUI_SECRET` - Django SECRET_KEY
- Future: Database passwords, API keys, etc.
### **ConfigMaps** (for non-sensitive configuration)
- `DEBUG` - Debug mode setting
- `DJANGO_ALLOWED_HOSTS` - Allowed hostnames
- `DJANGO_SETTINGS_MODULE` - Settings module path
- Application configuration settings
This approach ensures that:
1. Sensitive data is not stored in version control
2. Configuration is environment-specific
3. Non-sensitive settings are easily manageable
4. Follows Kubernetes best practices
5. Includes fallback for local development
## Files Changed
### VorgabenUI/settings.py
- Replaced hardcoded `SECRET_KEY` with `VORGABENUI_SECRET` environment variable lookup
- Added fallback secret key for local development (only works when DEBUG=True)
- Added warning when fallback key is used
### Files Created/Updated
#### **Configuration Resources**
- `argocd/configmap.yaml` - Django configuration (DEBUG, ALLOWED_HOSTS, etc.)
- `templates/configmap.yaml` - ConfigMap template (excluded from ArgoCD)
- `templates/secret.yaml` - Secret template (excluded from ArgoCD deployment)
- `argocd/secret.yaml` - ArgoCD-specific secret template with ignore annotation
#### **Deployment Configuration**
- `argocd/deployment.yaml` - Updated with Secret and ConfigMap environment variables
- `.argocdignore` - ArgoCD ignore patterns for templates and scripts
#### **Deployment Scripts**
- `scripts/deploy-argocd-secret.sh` - ArgoCD-specific script to deploy secrets
- `scripts/deploy-argocd-configmap.sh` - ArgoCD-specific script to deploy ConfigMap
#### **Application Code**
- `VorgabenUI/settings.py` - Updated to use environment variables from ConfigMap
#### **Examples and Documentation**
- `k8s/django-secret.yaml` - Updated for consistency (vorgabenui namespace)
- `k8s/django-deployment-example.yaml` - Updated example deployment
- `scripts/deploy-django-secret.sh` - Updated with new defaults
## Usage
### 1. Deploy ConfigMap (ArgoCD Production)
**Deploy configuration first** (required before the application starts):
```bash
# Deploy ConfigMap to vorgabenui namespace
./scripts/deploy-argocd-configmap.sh
# Verify existing ConfigMap
./scripts/deploy-argocd-configmap.sh --verify-only
# Dry run to see what would happen
./scripts/deploy-argocd-configmap.sh --dry-run
# Get help
./scripts/deploy-argocd-configmap.sh --help
```
### 2. Deploy the Secret (ArgoCD Production)
**Deploy secret second** (contains sensitive SECRET_KEY):
```bash
# Deploy secret to vorgabenui namespace
./scripts/deploy-argocd-secret.sh
# Verify existing secret
./scripts/deploy-argocd-secret.sh --verify-only
# Dry run to see what would happen
./scripts/deploy-argocd-secret.sh --dry-run
# Get help
./scripts/deploy-argocd-secret.sh --help
```
### 3. Deploy Resources for Other Environments
For development or other environments, use the general scripts:
```bash
# Deploy ConfigMap to vorgabenui namespace (default)
./scripts/deploy-django-secret.sh # (includes ConfigMap deployment)
# Deploy to specific namespace
./scripts/deploy-django-secret.sh -n development
# Get help
./scripts/deploy-django-secret.sh --help
```
### 4. Environment Variable Configuration
The ArgoCD deployment (`argocd/deployment.yaml`) is configured with:
**Secret Variables:**
```yaml
env:
# Secret configuration
- name: VORGABENUI_SECRET
valueFrom:
secretKeyRef:
name: vorgabenui-secrets
key: vorgabenui_secret
```
**ConfigMap Variables:**
```yaml
# ConfigMap configuration
- name: DEBUG
valueFrom:
configMapKeyRef:
name: django-config
key: DEBUG
- name: DJANGO_ALLOWED_HOSTS
valueFrom:
configMapKeyRef:
name: django-config
key: DJANGO_ALLOWED_HOSTS
- name: DJANGO_SETTINGS_MODULE
valueFrom:
configMapKeyRef:
name: django-config
key: DJANGO_SETTINGS_MODULE
```
For other deployments, see `k8s/django-deployment-example.yaml` for a complete example.
### 5. Verify the Deployment
**Check ConfigMap:**
```bash
kubectl get configmap django-config -n vorgabenui
kubectl describe configmap django-config -n vorgabenui
```
**Check Secret:**
```bash
kubectl get secrets vorgabenui-secrets -n vorgabenui
kubectl describe secret vorgabenui-secrets -n vorgabenui
```
**Check Django pods can access configuration:**
```bash
# Check secret variable
kubectl exec -n vorgabenui deployment/django -- printenv VORGABENUI_SECRET
# Check ConfigMap variables
kubectl exec -n vorgabenui deployment/django -- printenv DEBUG
kubectl exec -n vorgabenui deployment/django -- printenv DJANGO_ALLOWED_HOSTS
# Check all environment variables
kubectl exec -n vorgabenui deployment/django -- printenv | grep -E "(VORGABENUI|DEBUG|DJANGO)"
```
## Development Environment
### Local Development with Fallback
The application now includes a fallback secret key for local development. When running locally:
1. **Automatic fallback**: If `VORGABENUI_SECRET` is not set and `DEBUG=True`, a fallback key is used automatically
2. **Warning message**: The application will log a warning when using the fallback key (except during builds)
3. **Production safety**: Fallback only works when `DEBUG=True` or in build environments
### Docker Build Support
The Django settings are designed to work seamlessly during Docker builds:
1. **Build environment detection**: Automatically detects Docker builds, CI environments
2. **Fallback activation**: Uses fallback key during build without requiring environment variables
3. **No build-time secrets**: No need to provide `VORGABENUI_SECRET` during `docker build`
4. **Runtime security**: Production containers still require the proper environment variable
**Supported build environments:**
- Docker builds (`DOCKER_BUILDKIT`)
- CI environments (`CI`)
- GitHub Actions (`GITHUB_ACTIONS`)
- Gitea Actions (`GITEA_ACTIONS`)
- Local development (`DEBUG=True`)
### Manual Environment Variable
You can still set the environment variable manually:
```bash
# Option 1: Export the variable
export VORGABENUI_SECRET="your-development-key-here"
python manage.py runserver
# Option 2: Use a .env file (recommended)
echo "VORGABENUI_SECRET=your-development-key-here" > .env
# Then load it in your settings or use python-dotenv
```
### Development vs Production
- **Local Development**: Fallback key works automatically when `DEBUG=True`
- **Production**: Must have `VORGABENUI_SECRET` environment variable set, no fallback
## ArgoCD Integration and Exclusions
### Preventing ArgoCD from Deploying Secret Templates
This setup includes multiple approaches to prevent ArgoCD from trying to deploy the secret template:
#### 1. Template Directory (`templates/`)
- Secret template moved to `templates/` directory
- ArgoCD deployment script automatically uses this location
- Excluded via `.argocdignore` file
#### 2. ArgoCD Ignore Annotation
- `argocd/secret.yaml` has `argocd.argoproj.io/ignore: "true"` annotation
- Provides fallback if templates directory approach fails
#### 3. `.argocdignore` File
- Global exclusion patterns for templates, scripts, and documentation
- Prevents ArgoCD from syncing non-deployment files
### ArgoCD Sync Behavior
- ArgoCD will sync only the actual deployment files (`deployment.yaml`, `ingress.yaml`, etc.)
- Secret templates are excluded and must be deployed manually using the deployment script
- This ensures secrets are created outside of GitOps workflow for security
## Security Considerations
1. **Never commit the actual SECRET_KEY** - Only templates and scripts are in version control
2. **Use different keys per environment** - Production, staging, and development should all have unique keys
3. **Rotate keys regularly** - Run the deployment script periodically to generate new keys
4. **Limit access** - Use Kubernetes RBAC to control who can access secrets
5. **ArgoCD exclusion** - Secret templates are excluded from ArgoCD to prevent empty/template secrets from being deployed
## Troubleshooting
### Django fails to start with "VORGABENUI_SECRET environment variable is required"
This means the environment variable is not set in your pod and fallback conditions aren't met. Check:
1. **Secret exists**: `kubectl get secret vorgabenui-secrets -n vorgabenui`
2. **Deployment references secret correctly**: Check `argocd/deployment.yaml` env section
3. **Pod has environment variable**: `kubectl exec <pod-name> -n vorgabenui -- env | grep VORGABENUI_SECRET`
4. **For local development**: Ensure `DEBUG=True` to use the fallback key
5. **For Docker builds**: Build should work automatically with fallback
### Docker build fails with SECRET_KEY error
This should no longer happen with the updated settings. If you still see issues:
1. **Check build environment variables**: Build should detect `DOCKER_BUILDKIT=1`
2. **Verify settings changes**: Ensure the updated `settings.py` is being used
3. **Force environment detection**: Set `CI=1` during build if needed
4. **Use explicit DEBUG**: Set `DEBUG=True` during build as fallback
### Secret deployment fails
Check that:
1. You have kubectl access to the cluster
2. You have permission to create secrets in the `vorgabenui` namespace
3. Python3 is available for key generation
4. The ArgoCD secret template exists: `argocd/secret.yaml`
### Key rotation
To rotate the SECRET_KEY:
1. **For ArgoCD production**: Run `./scripts/deploy-argocd-secret.sh` again
2. **For other environments**: Run `./scripts/deploy-django-secret.sh` again
3. Restart your Django pods to pick up the new key:
```bash
# For ArgoCD production
kubectl rollout restart deployment/django -n vorgabenui
# For other environments
kubectl rollout restart deployment/your-django-deployment -n your-namespace
```
## Script Options
### ArgoCD Production Scripts
#### **ConfigMap Script (`deploy-argocd-configmap.sh`)**
Deploy Django configuration (non-sensitive):
- `--verify-only` - Only verify existing ConfigMap, don't deploy
- `--dry-run` - Show what would be deployed without applying
- `-h, --help` - Show help message
Configuration is hardcoded for ArgoCD:
- Namespace: `vorgabenui`
- ConfigMap name: `django-config`
- ConfigMap file: `argocd/configmap.yaml`
#### **Secret Script (`deploy-argocd-secret.sh`)**
Deploy sensitive configuration:
- `--verify-only` - Only verify existing secret, don't create new one
- `--dry-run` - Show what would be done without making changes
- `-h, --help` - Show help message
Configuration is hardcoded for ArgoCD:
- Namespace: `vorgabenui`
- Secret name: `vorgabenui-secrets`
- Secret key: `vorgabenui_secret`
- Template location: `templates/secret.yaml` (excluded from ArgoCD)
### General Script (`deploy-django-secret.sh`)
For development and other environments:
- `-n, --namespace NAMESPACE` - Target Kubernetes namespace (default: vorgabenui)
- `-s, --secret-name NAME` - Secret name (default: vorgabenui-secrets)
- `-k, --key-name NAME` - Secret key name (default: vorgabenui_secret)
- `-h, --help` - Show help message
Environment variables:
- `NAMESPACE` - Override default namespace
## Migration from Hardcoded Key
### Migration from Old Setup
If you're migrating from the previous `DJANGO_SECRET_KEY` setup:
1. **Deploy the new secret** using `./scripts/deploy-argocd-secret.sh`
2. **Update any existing deployments** to use `VORGABENUI_SECRET` instead of `DJANGO_SECRET_KEY`
3. **Test locally** - the fallback key should work automatically in DEBUG mode
4. **Deploy the updated application** - ArgoCD deployment is already configured
### Migration from Hardcoded Key
If you're migrating from a completely hardcoded key:
1. **Backup your current key** (in case you need to rollback)
2. **Deploy the secret first** using the deployment script
3. **Apply the updated ArgoCD deployment** (already done in this setup)
4. **Test thoroughly** - local development should work with fallback
5. **Deploy the updated settings.py** after confirming the secret works
The ArgoCD deployment (`argocd/deployment.yaml`) now includes the environment variable configuration, so Django will automatically pick up the secret after deployment.
## Deployment Order
**Critical: Deploy resources in this order:**
1. **ConfigMap first** (required for Django to start):
```bash
./scripts/deploy-argocd-configmap.sh
```
2. **Secret second** (contains sensitive data):
```bash
./scripts/deploy-argocd-secret.sh
```
3. **Application deployment** (ArgoCD will sync this automatically):
```bash
kubectl apply -f argocd/deployment.yaml
# OR let ArgoCD sync from Git
```
If you deploy in the wrong order, Django pods will fail to start because they require both the ConfigMap and Secret to be available.

View File

@@ -0,0 +1,39 @@
from django.core.management.base import BaseCommand
import xml.etree.ElementTree as ET
from dokumente.models import Dokument
from dokumente.utils import build_dokument_xml_element, prettify_xml
class Command(BaseCommand):
help = 'Export all dokumente as XML'
def add_arguments(self, parser):
parser.add_argument(
'--output',
type=str,
help='Output file path (default: stdout)',
)
def handle(self, *args, **options):
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')
root = ET.Element('Vorgabendokumente')
for dokument in dokumente:
build_dokument_xml_element(dokument, root)
xml_str = ET.tostring(root, encoding='unicode', method='xml')
xml_output = prettify_xml(xml_str)
if options['output']:
with open(options['output'], 'w', encoding='utf-8') as f:
f.write(xml_output)
self.stdout.write(self.style.SUCCESS(f'XML exported to {options["output"]}'))
else:
self.stdout.write(xml_output)

View File

@@ -12,7 +12,7 @@ class Dokumententyp(models.Model):
verantwortliche_ve = models.CharField(max_length=255)
def __str__(self):
return self.name
return str(self.name)
class Meta:
verbose_name="Dokumententyp"
@@ -28,6 +28,7 @@ class Person(models.Model):
class Meta:
verbose_name_plural="Personen"
ordering = ['name']
verbose_name="Person"
class Thema(models.Model):
name = models.CharField(max_length=100, primary_key=True)
@@ -37,7 +38,7 @@ class Thema(models.Model):
return self.name
class Meta:
verbose_name_plural="Themen"
verbose_name="Thema"
class Dokument(models.Model):
nummer = models.CharField(max_length=50, primary_key=True)
@@ -49,7 +50,7 @@ class Dokument(models.Model):
gueltigkeit_bis = models.DateField(null=True, blank=True)
signatur_cso = models.CharField(max_length=255, blank=True)
anhaenge = models.TextField(blank=True)
aktiv = models.BooleanField(blank=True)
aktiv = models.BooleanField(blank=True,default=False)
def __str__(self):
return f"{self.nummer} {self.name}"

View File

@@ -193,23 +193,28 @@
<h2>Metadaten</h2>
<div class="row mb-4">
<div class="col-md-12">
<dl class="row">
<dt class="col-sm-3">Autoren:</dt>
<dd class="col-sm-9">{{ standard.autoren.all|join:", " }}</dd>
<dl class="row">
<dt class="col-sm-3">Autoren:</dt>
<dd class="col-sm-9">{{ standard.autoren.all|join:", " }}</dd>
<dt class="col-sm-3">Prüfende:</dt>
<dd class="col-sm-9">{{ standard.pruefende.all|join:", " }}</dd>
<dt class="col-sm-3">Prüfende:</dt>
<dd class="col-sm-9">{{ standard.pruefende.all|join:", " }}</dd>
<dt class="col-sm-3">Gültigkeit:</dt>
<dd class="col-sm-9">{{ standard.gueltigkeit_von }} bis {{ standard.gueltigkeit_bis|default_if_none:"auf weiteres" }}</dd>
</dl>
<p>
<a href="{% url 'standard_json' standard.nummer %}"
class="btn btn-secondary icon icon--before icon--download"
download="{{ standard.nummer }}.json">
JSON herunterladen
</a>
</p>
<dt class="col-sm-3">Gültigkeit:</dt>
<dd class="col-sm-9">{{ standard.gueltigkeit_von }} bis {{ standard.gueltigkeit_bis|default_if_none:"auf weiteres" }}</dd>
</dl>
<p>
<a href="{% url 'standard_json' standard.nummer %}"
class="btn btn-secondary icon icon--before icon--download"
download="{{ standard.nummer }}.json">
JSON herunterladen
</a>
<a href="{% url 'standard_xml' standard.nummer %}"
class="btn btn-secondary icon icon--before icon--download"
download="{{ standard.nummer }}.xml">
XML herunterladen
</a>
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,960 @@
"""
Tests for the import-document management command.
This test suite covers:
- Basic import functionality
- Dry-run mode
- Purge functionality
- Error handling (missing file, dokumententyp, thema, abschnitttyp)
- Context switching (einleitung → geltungsbereich → vorgabe)
- Header normalization
- Vorgaben with Kurztext, Langtext, Stichworte, Checklistenfragen
- Edge cases and malformed input
"""
import os
import tempfile
from io import StringIO
from pathlib import Path
from django.test import TestCase
from django.core.management import call_command
from django.core.management.base import CommandError
from dokumente.models import (
Dokumententyp,
Dokument,
Thema,
Vorgabe,
VorgabeKurztext,
VorgabeLangtext,
Geltungsbereich,
Einleitung,
Checklistenfrage,
)
from abschnitte.models import AbschnittTyp
from stichworte.models import Stichwort
class ImportDocumentCommandTestCase(TestCase):
"""Test cases for the import-document management command"""
def setUp(self):
"""Set up test fixtures"""
# Create required Dokumententyp
self.dokumententyp = Dokumententyp.objects.create(
name="IT-Sicherheit",
verantwortliche_ve="TEST-VE"
)
# Create required AbschnittTyp instances
self.text_typ = AbschnittTyp.objects.create(abschnitttyp="text")
self.liste_geordnet_typ = AbschnittTyp.objects.create(
abschnitttyp="liste geordnet"
)
self.liste_ungeordnet_typ = AbschnittTyp.objects.create(
abschnitttyp="liste ungeordnet"
)
# Create test Themen
self.thema_organisation = Thema.objects.create(
name="Organisation",
erklaerung="Organisatorische Anforderungen"
)
self.thema_technik = Thema.objects.create(
name="Technik",
erklaerung="Technische Anforderungen"
)
# Additional Themen for r009.txt example
self.thema_informationen = Thema.objects.create(
name="Informationen",
erklaerung="Informationssicherheit"
)
self.thema_systeme = Thema.objects.create(
name="Systeme",
erklaerung="Systemanforderungen"
)
self.thema_anwendungen = Thema.objects.create(
name="Anwendungen",
erklaerung="Anwendungsanforderungen"
)
self.thema_zonen = Thema.objects.create(
name="Zonen",
erklaerung="Zonenanforderungen"
)
def create_test_file(self, content):
"""Helper to create a temporary test file with given content"""
fd, path = tempfile.mkstemp(suffix=".txt", text=True)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(content)
return path
def test_basic_import_creates_document(self):
"""Test that basic import creates a document"""
test_content = """>>>Einleitung
>>>text
This is the introduction.
>>>geltungsbereich
>>>text
This is the scope.
>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
Test Requirement
>>>Kurztext
>>>Text
Short description.
>>>Langtext
>>>Text
Long description.
"""
test_file = self.create_test_file(test_content)
try:
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-001',
'--name', 'Test Document',
'--dokumententyp', 'IT-Sicherheit',
stdout=out
)
# Check document was created
dokument = Dokument.objects.get(nummer='TEST-001')
self.assertEqual(dokument.name, 'Test Document')
self.assertEqual(dokument.dokumententyp, self.dokumententyp)
# Check output message
output = out.getvalue()
self.assertIn('Created Document TEST-001', output)
self.assertIn('Imported document TEST-001', output)
finally:
os.unlink(test_file)
def test_import_creates_einleitung(self):
"""Test that Einleitung sections are created"""
test_content = """>>>Einleitung
>>>text
This is the introduction text.
>>>geltungsbereich
>>>text
Scope text.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-002',
'--name', 'Test Document 2',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-002')
einleitung = Einleitung.objects.filter(einleitung=dokument)
self.assertEqual(einleitung.count(), 1)
self.assertEqual(einleitung.first().inhalt, 'This is the introduction text.')
self.assertEqual(einleitung.first().abschnitttyp, self.text_typ)
finally:
os.unlink(test_file)
def test_import_creates_geltungsbereich(self):
"""Test that Geltungsbereich sections are created"""
test_content = """>>>geltungsbereich
>>>text
This standard applies to all servers.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-003',
'--name', 'Test Document 3',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-003')
geltungsbereich = Geltungsbereich.objects.filter(geltungsbereich=dokument)
self.assertEqual(geltungsbereich.count(), 1)
self.assertEqual(
geltungsbereich.first().inhalt,
'This standard applies to all servers.'
)
self.assertEqual(geltungsbereich.first().abschnitttyp, self.text_typ)
finally:
os.unlink(test_file)
def test_import_creates_vorgabe_with_all_fields(self):
"""Test creating a Vorgabe with all fields"""
test_content = """>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
Complete Requirement
>>>Kurztext
>>>Text
Short text here.
>>>Langtext
>>>Text
Long text here.
>>>Stichworte
Testing, Management, Security
>>>Checkliste
Is the requirement met?
Has documentation been provided?
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-004',
'--name', 'Test Document 4',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-004')
vorgabe = Vorgabe.objects.get(dokument=dokument, nummer=1)
# Check basic fields
self.assertEqual(vorgabe.titel, 'Complete Requirement')
self.assertEqual(vorgabe.thema, self.thema_organisation)
# Check Kurztext
kurztext = VorgabeKurztext.objects.filter(abschnitt=vorgabe)
self.assertEqual(kurztext.count(), 1)
self.assertEqual(kurztext.first().inhalt, 'Short text here.')
# Check Langtext
langtext = VorgabeLangtext.objects.filter(abschnitt=vorgabe)
self.assertEqual(langtext.count(), 1)
self.assertEqual(langtext.first().inhalt, 'Long text here.')
# Check Stichworte
stichworte = vorgabe.stichworte.all()
self.assertEqual(stichworte.count(), 3)
stichwort_names = {s.stichwort for s in stichworte}
self.assertEqual(stichwort_names, {'Testing', 'Management', 'Security'})
# Check Checklistenfragen
fragen = Checklistenfrage.objects.filter(vorgabe=vorgabe)
self.assertEqual(fragen.count(), 2)
frage_texts = {f.frage for f in fragen}
self.assertEqual(frage_texts, {
'Is the requirement met?',
'Has documentation been provided?'
})
finally:
os.unlink(test_file)
def test_import_multiple_vorgaben(self):
"""Test importing multiple Vorgaben"""
test_content = """>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
First Requirement
>>>Kurztext
>>>Text
First requirement text.
>>>Vorgabe Technik
>>>Nummer 2
>>>Titel
Second Requirement
>>>Kurztext
>>>Text
Second requirement text.
>>>Vorgabe Organisation
>>>Nummer 3
>>>Titel
Third Requirement
>>>Kurztext
>>>Text
Third requirement text.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-005',
'--name', 'Test Document 5',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-005')
vorgaben = Vorgabe.objects.filter(dokument=dokument).order_by('nummer')
self.assertEqual(vorgaben.count(), 3)
self.assertEqual(vorgaben[0].nummer, 1)
self.assertEqual(vorgaben[0].thema, self.thema_organisation)
self.assertEqual(vorgaben[1].nummer, 2)
self.assertEqual(vorgaben[1].thema, self.thema_technik)
self.assertEqual(vorgaben[2].nummer, 3)
self.assertEqual(vorgaben[2].thema, self.thema_organisation)
finally:
os.unlink(test_file)
def test_dry_run_creates_no_data(self):
"""Test that dry-run mode creates no database records"""
test_content = """>>>Einleitung
>>>text
Introduction text.
>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
Test Requirement
>>>Kurztext
>>>Text
Short text.
"""
test_file = self.create_test_file(test_content)
try:
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-DRY',
'--name', 'Dry Run Test',
'--dokumententyp', 'IT-Sicherheit',
'--dry-run',
stdout=out
)
# Document is created (for counting purposes) but not saved
output = out.getvalue()
self.assertIn('Dry run: no database changes will be made', output)
self.assertIn('Dry run complete', output)
# Check that Einleitung and Vorgabe were NOT created
dokument = Dokument.objects.get(nummer='TEST-DRY')
self.assertEqual(Einleitung.objects.filter(einleitung=dokument).count(), 0)
self.assertEqual(Vorgabe.objects.filter(dokument=dokument).count(), 0)
finally:
os.unlink(test_file)
def test_dry_run_verbose_shows_details(self):
"""Test that dry-run with verbose shows detailed output"""
test_content = """>>>Einleitung
>>>text
Introduction.
>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
Test
>>>Kurztext
>>>Text
Short.
>>>Langtext
>>>Text
Long.
>>>Stichworte
Keyword1, Keyword2
>>>Checkliste
Question 1?
Question 2?
"""
test_file = self.create_test_file(test_content)
try:
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-VERBOSE',
'--name', 'Verbose Test',
'--dokumententyp', 'IT-Sicherheit',
'--dry-run',
'--verbose',
stdout=out
)
output = out.getvalue()
self.assertIn('[DRY RUN] Einleitung Abschnitt', output)
self.assertIn('[DRY RUN] Would create Vorgabe 1', output)
self.assertIn('Stichworte: Keyword1, Keyword2', output)
self.assertIn('Checkliste: Question 1?', output)
self.assertIn('Checkliste: Question 2?', output)
self.assertIn('Kurztext', output)
self.assertIn('Langtext', output)
finally:
os.unlink(test_file)
def test_purge_deletes_existing_content(self):
"""Test that --purge deletes existing content before import"""
test_content = """>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
New Requirement
>>>Kurztext
>>>Text
New text.
"""
test_file = self.create_test_file(test_content)
try:
# First import
call_command(
'import-document',
test_file,
'--nummer', 'TEST-PURGE',
'--name', 'Purge Test',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-PURGE')
self.assertEqual(Vorgabe.objects.filter(dokument=dokument).count(), 1)
# Second import with different content and --purge
test_content_2 = """>>>Vorgabe Technik
>>>Nummer 2
>>>Titel
Replacement Requirement
>>>Kurztext
>>>Text
Replacement text.
"""
test_file_2 = self.create_test_file(test_content_2)
try:
out = StringIO()
call_command(
'import-document',
test_file_2,
'--nummer', 'TEST-PURGE',
'--name', 'Purge Test',
'--dokumententyp', 'IT-Sicherheit',
'--purge',
stdout=out
)
# Old Vorgabe should be deleted, only new one exists
vorgaben = Vorgabe.objects.filter(dokument=dokument)
self.assertEqual(vorgaben.count(), 1)
self.assertEqual(vorgaben.first().nummer, 2)
self.assertEqual(vorgaben.first().thema, self.thema_technik)
output = out.getvalue()
self.assertIn('Purged', output)
finally:
os.unlink(test_file_2)
finally:
os.unlink(test_file)
def test_purge_dry_run_shows_what_would_be_deleted(self):
"""Test that --purge with --dry-run shows deletion counts"""
test_content = """>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
Original
>>>Kurztext
>>>Text
Text.
"""
test_file = self.create_test_file(test_content)
try:
# First import to create data
call_command(
'import-document',
test_file,
'--nummer', 'TEST-PURGE-DRY',
'--name', 'Purge Dry Test',
'--dokumententyp', 'IT-Sicherheit'
)
# Dry run with purge
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-PURGE-DRY',
'--name', 'Purge Dry Test',
'--dokumententyp', 'IT-Sicherheit',
'--purge',
'--dry-run',
stdout=out
)
output = out.getvalue()
self.assertIn('[DRY RUN] Would purge:', output)
self.assertIn('1 Vorgaben', output)
finally:
os.unlink(test_file)
def test_header_normalization(self):
"""Test that headers with hyphens are normalized correctly"""
test_content = """>>>geltungsbereich
>>>Liste-ungeordnet
Item 1
Item 2
Item 3
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-NORM',
'--name', 'Normalization Test',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-NORM')
geltungsbereich = Geltungsbereich.objects.get(geltungsbereich=dokument)
# Should have normalized "Liste-ungeordnet" to "liste ungeordnet"
self.assertEqual(geltungsbereich.abschnitttyp, self.liste_ungeordnet_typ)
finally:
os.unlink(test_file)
def test_missing_file_raises_error(self):
"""Test that missing file raises CommandError"""
with self.assertRaises(CommandError) as cm:
call_command(
'import-document',
'/nonexistent/file.txt',
'--nummer', 'TEST-ERR',
'--name', 'Error Test',
'--dokumententyp', 'IT-Sicherheit'
)
self.assertIn('does not exist', str(cm.exception))
def test_missing_dokumententyp_raises_error(self):
"""Test that missing Dokumententyp raises CommandError"""
test_content = """>>>geltungsbereich
>>>text
Text.
"""
test_file = self.create_test_file(test_content)
try:
with self.assertRaises(CommandError) as cm:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-ERR',
'--name', 'Error Test',
'--dokumententyp', 'NonExistentType'
)
self.assertIn('does not exist', str(cm.exception))
finally:
os.unlink(test_file)
def test_missing_thema_skips_vorgabe(self):
"""Test that missing Thema causes Vorgabe to be skipped with warning"""
test_content = """>>>Vorgabe NonExistentThema
>>>Nummer 1
>>>Titel
Test
>>>Kurztext
>>>Text
Text.
"""
test_file = self.create_test_file(test_content)
try:
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-SKIP',
'--name', 'Skip Test',
'--dokumententyp', 'IT-Sicherheit',
stdout=out
)
dokument = Dokument.objects.get(nummer='TEST-SKIP')
# Vorgabe should NOT be created
self.assertEqual(Vorgabe.objects.filter(dokument=dokument).count(), 0)
output = out.getvalue()
self.assertIn('not found, skipping Vorgabe', output)
finally:
os.unlink(test_file)
def test_missing_abschnitttyp_defaults_to_text(self):
"""Test that missing AbschnittTyp defaults to 'text' with warning"""
# Delete all but text type
AbschnittTyp.objects.exclude(abschnitttyp='text').delete()
test_content = """>>>geltungsbereich
>>>liste geordnet
Item 1
"""
test_file = self.create_test_file(test_content)
try:
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-DEFAULT',
'--name', 'Default Test',
'--dokumententyp', 'IT-Sicherheit',
stdout=out
)
dokument = Dokument.objects.get(nummer='TEST-DEFAULT')
geltungsbereich = Geltungsbereich.objects.get(geltungsbereich=dokument)
# Should default to 'text' type
self.assertEqual(geltungsbereich.abschnitttyp.abschnitttyp, 'text')
output = out.getvalue()
self.assertIn("not found; defaulting to 'text'", output)
finally:
os.unlink(test_file)
def test_inline_titel(self):
"""Test that inline title (on same line as header) is parsed"""
test_content = """>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel Inline Title Here
>>>Kurztext
>>>Text
Text.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-INLINE',
'--name', 'Inline Test',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-INLINE')
vorgabe = Vorgabe.objects.get(dokument=dokument)
self.assertEqual(vorgabe.titel, 'Inline Title Here')
finally:
os.unlink(test_file)
def test_inline_stichworte(self):
"""Test that inline Stichworte (on same line as header) are parsed"""
test_content = """>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel Test
>>>Stichworte Security, Testing, Compliance
>>>Kurztext
>>>Text
Text.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-INLINE-STW',
'--name', 'Inline Stichwort Test',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-INLINE-STW')
vorgabe = Vorgabe.objects.get(dokument=dokument)
stichworte = {s.stichwort for s in vorgabe.stichworte.all()}
self.assertEqual(stichworte, {'Security', 'Testing', 'Compliance'})
finally:
os.unlink(test_file)
def test_gueltigkeit_dates(self):
"""Test that validity dates are set correctly"""
test_content = """>>>geltungsbereich
>>>text
Scope.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-DATES',
'--name', 'Date Test',
'--dokumententyp', 'IT-Sicherheit',
'--gueltigkeit_von', '2024-01-01',
'--gueltigkeit_bis', '2024-12-31'
)
dokument = Dokument.objects.get(nummer='TEST-DATES')
self.assertEqual(str(dokument.gueltigkeit_von), '2024-01-01')
self.assertEqual(str(dokument.gueltigkeit_bis), '2024-12-31')
finally:
os.unlink(test_file)
def test_existing_document_updates(self):
"""Test that importing to existing document number shows warning"""
test_content = """>>>geltungsbereich
>>>text
First version.
"""
test_file = self.create_test_file(test_content)
try:
# First import
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-EXISTS',
'--name', 'Existing Test',
'--dokumententyp', 'IT-Sicherheit',
stdout=out
)
output1 = out.getvalue()
self.assertIn('Created Document TEST-EXISTS', output1)
# Second import with same number
out2 = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-EXISTS',
'--name', 'Existing Test',
'--dokumententyp', 'IT-Sicherheit',
stdout=out2
)
output2 = out2.getvalue()
self.assertIn('already exists', output2)
finally:
os.unlink(test_file)
def test_multiple_kurztext_sections(self):
"""Test Vorgabe with multiple Kurztext sections"""
test_content = """>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel Multiple Sections
>>>Kurztext
>>>Text
First kurztext section.
>>>Liste ungeordnet
Item A
Item B
>>>Langtext
>>>Text
Langtext.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-MULTI',
'--name', 'Multi Section Test',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-MULTI')
vorgabe = Vorgabe.objects.get(dokument=dokument)
kurztext_sections = VorgabeKurztext.objects.filter(abschnitt=vorgabe).order_by('id')
self.assertEqual(kurztext_sections.count(), 2)
self.assertEqual(kurztext_sections[0].abschnitttyp.abschnitttyp, 'text')
self.assertEqual(kurztext_sections[1].abschnitttyp.abschnitttyp, 'liste ungeordnet')
finally:
os.unlink(test_file)
def test_empty_file(self):
"""Test importing an empty file"""
test_content = ""
test_file = self.create_test_file(test_content)
try:
out = StringIO()
call_command(
'import-document',
test_file,
'--nummer', 'TEST-EMPTY',
'--name', 'Empty Test',
'--dokumententyp', 'IT-Sicherheit',
stdout=out
)
dokument = Dokument.objects.get(nummer='TEST-EMPTY')
# Document created but no content
self.assertEqual(Einleitung.objects.filter(einleitung=dokument).count(), 0)
self.assertEqual(Geltungsbereich.objects.filter(geltungsbereich=dokument).count(), 0)
self.assertEqual(Vorgabe.objects.filter(dokument=dokument).count(), 0)
output = out.getvalue()
self.assertIn('with 0 Vorgaben', output)
finally:
os.unlink(test_file)
def test_unicode_content(self):
"""Test that Unicode characters (German umlauts, etc.) are handled correctly"""
test_content = """>>>Einleitung
>>>text
Übersicht über die Sicherheitsanforderungen für IT-Systeme.
>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel
Überprüfung der Systemkonfiguration
>>>Kurztext
>>>Text
Die Konfiguration muss regelmäßig überprüft werden.
>>>Stichworte
Überprüfung, Sicherheit, Qualität
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-UNICODE',
'--name', 'Unicode Test',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-UNICODE')
# Check Einleitung
einleitung = Einleitung.objects.get(einleitung=dokument)
self.assertIn('Übersicht', einleitung.inhalt)
# Check Vorgabe
vorgabe = Vorgabe.objects.get(dokument=dokument)
self.assertEqual(vorgabe.titel, 'Überprüfung der Systemkonfiguration')
# Check Kurztext
kurztext = VorgabeKurztext.objects.get(abschnitt=vorgabe)
self.assertIn('regelmäßig', kurztext.inhalt)
# Check Stichworte
stichworte = {s.stichwort for s in vorgabe.stichworte.all()}
self.assertIn('Überprüfung', stichworte)
finally:
os.unlink(test_file)
def test_context_switching(self):
"""Test that context switches correctly between sections"""
test_content = """>>>Einleitung
>>>text
Intro text 1.
>>>text
Intro text 2.
>>>geltungsbereich
>>>text
Scope text 1.
>>>text
Scope text 2.
>>>Vorgabe Organisation
>>>Nummer 1
>>>Titel Test
>>>Kurztext
>>>text
Kurztext 1.
>>>text
Kurztext 2.
>>>Langtext
>>>text
Langtext 1.
"""
test_file = self.create_test_file(test_content)
try:
call_command(
'import-document',
test_file,
'--nummer', 'TEST-CONTEXT',
'--name', 'Context Test',
'--dokumententyp', 'IT-Sicherheit'
)
dokument = Dokument.objects.get(nummer='TEST-CONTEXT')
# Check Einleitung has 2 sections
einleitung = Einleitung.objects.filter(einleitung=dokument)
self.assertEqual(einleitung.count(), 2)
# Check Geltungsbereich has 2 sections
geltungsbereich = Geltungsbereich.objects.filter(geltungsbereich=dokument)
self.assertEqual(geltungsbereich.count(), 2)
# Check Vorgabe has correct Kurztext and Langtext counts
vorgabe = Vorgabe.objects.get(dokument=dokument)
kurztext = VorgabeKurztext.objects.filter(abschnitt=vorgabe)
langtext = VorgabeLangtext.objects.filter(abschnitt=vorgabe)
self.assertEqual(kurztext.count(), 2)
self.assertEqual(langtext.count(), 1)
finally:
os.unlink(test_file)
def test_real_world_example(self):
"""Test importing the real r009.txt example document"""
# Use the actual example file
example_file = Path(__file__).parent.parent / 'Documentation' / 'import formats' / 'r009.txt'
if not example_file.exists():
self.skipTest("r009.txt example file not found")
out = StringIO()
call_command(
'import-document',
str(example_file),
'--nummer', 'R009',
'--name', 'IT-Sicherheit Serversysteme',
'--dokumententyp', 'IT-Sicherheit',
stdout=out
)
dokument = Dokument.objects.get(nummer='R009')
# Check that Einleitung was created
self.assertGreater(Einleitung.objects.filter(einleitung=dokument).count(), 0)
# Check that Geltungsbereich was created
self.assertGreater(Geltungsbereich.objects.filter(geltungsbereich=dokument).count(), 0)
# Check that multiple Vorgaben were created (r009.txt has 23 Vorgaben)
vorgaben = Vorgabe.objects.filter(dokument=dokument)
self.assertGreaterEqual(vorgaben.count(), 20)
# Verify output message
output = out.getvalue()
self.assertIn('Imported document R009', output)

View File

@@ -1483,6 +1483,248 @@ class JSONExportManagementCommandTest(TestCase):
self.assertIn('"Test Standard"', output)
class ExportXMLCommandTest(TestCase):
"""Test cases for export_xml management command"""
def setUp(self):
"""Set up test data for XML export"""
# Create test data
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.autor1 = Person.objects.create(
name="Max Mustermann",
funktion="Security Analyst"
)
self.autor2 = Person.objects.create(
name="Erika Mustermann",
funktion="Security Manager"
)
self.thema = Thema.objects.create(
name="Access Control",
erklaerung="Zugangskontrolle"
)
self.dokument = Dokument.objects.create(
nummer="TEST-001",
dokumententyp=self.dokumententyp,
name="Test Standard",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31),
signatur_cso="CSO-123",
anhaenge="Anhang1.pdf, Anhang2.pdf",
aktiv=True
)
self.dokument.autoren.add(self.autor1, self.autor2)
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="Test Vorgabe",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31)
)
# Create text sections
self.abschnitttyp_text = AbschnittTyp.objects.create(abschnitttyp="text")
self.abschnitttyp_table = AbschnittTyp.objects.create(abschnitttyp="table")
self.geltungsbereich = Geltungsbereich.objects.create(
geltungsbereich=self.dokument,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist der Geltungsbereich",
order=1
)
self.einleitung = Einleitung.objects.create(
einleitung=self.dokument,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist die Einleitung",
order=1
)
self.kurztext = VorgabeKurztext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_text,
inhalt="Dies ist der Kurztext",
order=1
)
self.langtext = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_table,
inhalt="Spalte1|Spalte2\nWert1|Wert2",
order=1
)
self.checklistenfrage = Checklistenfrage.objects.create(
vorgabe=self.vorgabe,
frage="Ist die Zugriffskontrolle implementiert?"
)
self.changelog = Changelog.objects.create(
dokument=self.dokument,
datum=date(2023, 6, 1),
aenderung="Erste Version erstellt"
)
self.changelog.autoren.add(self.autor1)
def test_export_xml_command_stdout(self):
"""Test export_xml command output to stdout"""
out = StringIO()
call_command('export_xml', stdout=out)
output = out.getvalue()
# Check that output contains expected XML structure
self.assertIn('<Vorgabendokumente>', output)
self.assertIn('<Vorgabendokument>', output)
self.assertIn('<Typ>Standard IT-Sicherheit</Typ>', output)
self.assertIn('<Nummer>TEST-001</Nummer>', output)
self.assertIn('<Name>Test Standard</Name>', output)
self.assertIn('<Autor>Max Mustermann</Autor>', output)
self.assertIn('<Autor>Erika Mustermann</Autor>', output)
self.assertIn('<Von>2023-01-01</Von>', output)
self.assertIn('<Bis>2025-12-31</Bis>', output)
self.assertIn('<SignaturCSO>CSO-123</SignaturCSO>', output)
self.assertIn('Dies ist der Geltungsbereich', output)
self.assertIn('Dies ist die Einleitung', output)
self.assertIn('Dies ist der Kurztext', output)
self.assertIn('<Frage>Ist die Zugriffskontrolle implementiert?</Frage>', output)
self.assertIn('Erste Version erstellt', output)
def test_export_xml_command_to_file(self):
"""Test export_xml command output to file"""
import tempfile
import os
import xml.etree.ElementTree as ET
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.xml') as tmp_file:
tmp_filename = tmp_file.name
try:
call_command('export_xml', output=tmp_filename)
# Read file content
with open(tmp_filename, 'r', encoding='utf-8') as f:
content = f.read()
# Parse XML to ensure it's valid
root = ET.fromstring(content)
# Verify structure
self.assertEqual(root.tag, 'Vorgabendokumente')
docs = root.findall('Vorgabendokument')
self.assertEqual(len(docs), 1)
doc = docs[0]
self.assertEqual(doc.find('Nummer').text, 'TEST-001')
self.assertEqual(doc.find('Name').text, 'Test Standard')
self.assertEqual(doc.find('Typ').text, 'Standard IT-Sicherheit')
autoren = doc.find('Autoren').findall('Autor')
self.assertEqual(len(autoren), 2)
autor_names = [autor.text for autor in autoren]
self.assertIn('Max Mustermann', autor_names)
self.assertIn('Erika Mustermann', autor_names)
finally:
# Clean up temporary file
if os.path.exists(tmp_filename):
os.unlink(tmp_filename)
def test_export_xml_command_empty_database(self):
"""Test export_xml command with no documents"""
# Delete all documents
Dokument.objects.all().delete()
out = StringIO()
call_command('export_xml', stdout=out)
output = out.getvalue()
# Should output empty XML structure (accepts both formats)
self.assertIn('Vorgabendokumente', output)
self.assertNotIn('<Vorgabendokument>', output)
def test_export_xml_command_inactive_documents(self):
"""Test export_xml command filters inactive documents"""
# Create inactive document
inactive_doc = Dokument.objects.create(
nummer="INACTIVE-001",
dokumententyp=self.dokumententyp,
name="Inactive Document",
aktiv=False
)
out = StringIO()
call_command('export_xml', stdout=out)
output = out.getvalue()
# Should not contain inactive document
self.assertNotIn('INACTIVE-001', output)
self.assertNotIn('Inactive Document', output)
# Should still contain active document
self.assertIn('TEST-001', output)
self.assertIn('Test Standard', output)
def test_export_xml_command_table_structure(self):
"""Test export_xml command converts markdown tables to proper XML structure"""
# Create document with table
table_doc = Dokument.objects.create(
nummer="TABLE-001",
dokumententyp=self.dokumententyp,
name="Table Test Document",
aktiv=True
)
table_doc.autoren.add(self.autor1)
table_vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=table_doc,
thema=self.thema,
titel="Table Test Vorgabe",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31)
)
table_content = "| Spalte1 | Spalte2 |\n|---------|---------|\n| Wert1 | Wert2 |\n| Wert3 | Wert4 |"
self.langtext_table = VorgabeLangtext.objects.create(
abschnitt=table_vorgabe,
abschnitttyp=self.abschnitttyp_table,
inhalt=table_content,
order=1
)
out = StringIO()
call_command('export_xml', stdout=out)
output = out.getvalue()
# Check that table structure is properly exported
self.assertIn('<table>', output)
self.assertIn('<header>', output)
self.assertIn('<column>Spalte1</column>', output)
self.assertIn('<column>Spalte2</column>', output)
self.assertIn('<row>', output)
self.assertIn('<column>Wert1</column>', output)
self.assertIn('<column>Wert2</column>', output)
self.assertIn('<column>Wert3</column>', output)
self.assertIn('<column>Wert4</column>', output)
# Should not contain the markdown table content as plain text
self.assertNotIn('| Spalte1 | Spalte2 |', output)
class StandardJSONViewTest(TestCase):
"""Test cases for standard_json view"""
@@ -1668,6 +1910,261 @@ class StandardJSONViewTest(TestCase):
self.assertIn(' ', response.content.decode()) # Check for indentation
class StandardXMLViewTest(TestCase):
"""Test cases for standard_xml view"""
def setUp(self):
"""Set up test data for XML view"""
self.client = Client()
# Create test data
self.dokumententyp = Dokumententyp.objects.create(
name="Standard IT-Sicherheit",
verantwortliche_ve="SR-SUR-SEC"
)
self.autor = Person.objects.create(
name="Test Autor",
funktion="Security Analyst"
)
self.pruefender = Person.objects.create(
name="Test Pruefender",
funktion="Security Manager"
)
self.thema = Thema.objects.create(
name="Access Control",
erklaerung="Zugangskontrolle"
)
self.dokument = Dokument.objects.create(
nummer="XML-001",
dokumententyp=self.dokumententyp,
name="XML Test Standard",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31),
signatur_cso="CSO-456",
anhaenge="test.pdf",
aktiv=True
)
self.dokument.autoren.add(self.autor)
self.dokument.pruefende.add(self.pruefender)
self.vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=self.dokument,
thema=self.thema,
titel="XML Test Vorgabe",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31)
)
# Create text sections
self.abschnitttyp_text = AbschnittTyp.objects.create(abschnitttyp="text")
self.abschnitttyp_table = AbschnittTyp.objects.create(abschnitttyp="table")
self.geltungsbereich = Geltungsbereich.objects.create(
geltungsbereich=self.dokument,
abschnitttyp=self.abschnitttyp_text,
inhalt="XML Geltungsbereich",
order=1
)
self.kurztext = VorgabeKurztext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_text,
inhalt="XML Kurztext",
order=1
)
self.langtext = VorgabeLangtext.objects.create(
abschnitt=self.vorgabe,
abschnitttyp=self.abschnitttyp_text,
inhalt="XML Langtext",
order=1
)
self.checklistenfrage = Checklistenfrage.objects.create(
vorgabe=self.vorgabe,
frage="XML Checklistenfrage?"
)
self.changelog = Changelog.objects.create(
dokument=self.dokument,
datum=date(2023, 6, 1),
aenderung="XML Changelog Eintrag"
)
self.changelog.autoren.add(self.autor)
def test_standard_xml_view_success(self):
"""Test standard_xml view returns correct XML"""
url = reverse('standard_xml', kwargs={'nummer': 'XML-001'})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/xml; charset=utf-8')
self.assertIn('attachment', response['Content-Disposition'])
self.assertIn('XML-001.xml', response['Content-Disposition'])
# Parse XML response
import xml.etree.ElementTree as ET
root = ET.fromstring(response.content)
# Verify document structure
self.assertEqual(root.tag, 'Vorgabendokument')
self.assertEqual(root.find('Nummer').text, 'XML-001')
self.assertEqual(root.find('Name').text, 'XML Test Standard')
self.assertEqual(root.find('Typ').text, 'Standard IT-Sicherheit')
self.assertEqual(root.find('Autoren').find('Autor').text, 'Test Autor')
self.assertEqual(root.find('Pruefende').find('Pruefender').text, 'Test Pruefender')
self.assertEqual(root.find('Gueltigkeit').find('Von').text, '2023-01-01')
self.assertEqual(root.find('Gueltigkeit').find('Bis').text, '2025-12-31')
self.assertEqual(root.find('SignaturCSO').text, 'CSO-456')
self.assertEqual(root.find('Anhaenge').find('Anhang').text, 'test.pdf')
self.assertEqual(root.find('Verantwortlich').text, 'Information Security Management BIT')
self.assertIn(root.find('Klassifizierung').text, ['', None]) # Empty string or None
def test_standard_xml_view_not_found(self):
"""Test standard_xml view returns 404 for non-existent document"""
url = reverse('standard_xml', kwargs={'nummer': 'NONEXISTENT'})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_standard_xml_view_empty_sections(self):
"""Test standard_xml view handles empty sections correctly"""
# Create document without sections
empty_doc = Dokument.objects.create(
nummer="EMPTY-XML-001",
dokumententyp=self.dokumententyp,
name="Empty Document",
aktiv=True
)
url = reverse('standard_xml', kwargs={'nummer': 'EMPTY-XML-001'})
response = self.client.get(url)
# Parse XML response
import xml.etree.ElementTree as ET
root = ET.fromstring(response.content)
# Verify empty sections are handled correctly
self.assertIsNone(root.find('Geltungsbereich'))
self.assertIsNone(root.find('Einleitung'))
self.assertEqual(len(root.find('Vorgaben')), 0)
self.assertEqual(len(root.find('Changelog')), 0)
def test_standard_xml_view_null_dates(self):
"""Test standard_xml view handles null dates correctly"""
# Create document with null dates
null_doc = Dokument.objects.create(
nummer="NULL-XML-001",
dokumententyp=self.dokumententyp,
name="Null Dates Document",
gueltigkeit_von=None,
gueltigkeit_bis=None,
aktiv=True
)
url = reverse('standard_xml', kwargs={'nummer': 'NULL-XML-001'})
response = self.client.get(url)
# Parse XML response
import xml.etree.ElementTree as ET
root = ET.fromstring(response.content)
# Verify null dates are handled correctly
self.assertIn(root.find('Gueltigkeit').find('Von').text, ['', None])
self.assertIsNone(root.find('Gueltigkeit').find('Bis').text)
def test_standard_xml_view_xml_formatting(self):
"""Test standard_xml view returns properly formatted XML"""
url = reverse('standard_xml', kwargs={'nummer': 'XML-001'})
response = self.client.get(url)
# Check that response is valid XML
import xml.etree.ElementTree as ET
try:
ET.fromstring(response.content)
xml_valid = True
except ET.ParseError:
xml_valid = False
self.assertTrue(xml_valid)
# Check that XML is properly indented (should be formatted)
self.assertIn('<?xml version', response.content.decode())
self.assertIn('\n', response.content.decode())
self.assertIn(' ', response.content.decode()) # Check for indentation
def test_standard_xml_view_table_structure(self):
"""Test standard_xml view converts markdown tables to proper XML structure"""
# Create document with table
table_doc = Dokument.objects.create(
nummer="TABLE-XML-001",
dokumententyp=self.dokumententyp,
name="Table XML Test Document",
aktiv=True
)
table_doc.autoren.add(self.autor)
table_vorgabe = Vorgabe.objects.create(
order=1,
nummer=1,
dokument=table_doc,
thema=self.thema,
titel="Table XML Test Vorgabe",
gueltigkeit_von=date(2023, 1, 1),
gueltigkeit_bis=date(2025, 12, 31)
)
table_content = "| Col1 | Col2 |\n|------|------|\n| A | B |\n| C | D |"
langtext_table = VorgabeLangtext.objects.create(
abschnitt=table_vorgabe,
abschnitttyp=self.abschnitttyp_table,
inhalt=table_content,
order=1
)
url = reverse('standard_xml', kwargs={'nummer': 'TABLE-XML-001'})
response = self.client.get(url)
# Parse XML response
import xml.etree.ElementTree as ET
root = ET.fromstring(response.content)
# Find table element
table = root.find('.//table')
self.assertIsNotNone(table, 'Table element should exist')
# Check header structure
header = table.find('header')
self.assertIsNotNone(header, 'Header should exist')
header_cols = header.findall('column')
self.assertEqual(len(header_cols), 2, 'Header should have 2 columns')
self.assertEqual(header_cols[0].text, 'Col1')
self.assertEqual(header_cols[1].text, 'Col2')
# Check row structure
rows = table.findall('row')
self.assertEqual(len(rows), 2, 'Should have 2 data rows')
# Check first row
row1_cols = rows[0].findall('column')
self.assertEqual(len(row1_cols), 2)
self.assertEqual(row1_cols[0].text, 'A')
self.assertEqual(row1_cols[1].text, 'B')
# Check second row
row2_cols = rows[1].findall('column')
self.assertEqual(len(row2_cols), 2)
self.assertEqual(row2_cols[0].text, 'C')
self.assertEqual(row2_cols[1].text, 'D')
class VorgabeCommentModelTest(TestCase):
"""Test cases for VorgabeComment model"""

View File

@@ -11,6 +11,7 @@ urlpatterns = [
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>/json/', views.standard_json, name='standard_json'),
path('<str:nummer>/xml/', views.standard_xml, name='standard_xml'),
path('comments/<int:vorgabe_id>/', views.get_vorgabe_comments, name='get_vorgabe_comments'),
path('comments/<int:vorgabe_id>/add/', views.add_vorgabe_comment, name='add_vorgabe_comment'),
path('comments/delete/<int:comment_id>/', views.delete_vorgabe_comment, name='delete_vorgabe_comment'),

View File

@@ -1,7 +1,9 @@
"""
Utility functions for Vorgaben sanity checking
Utility functions for Vorgaben sanity checking and XML export
"""
import datetime
import xml.etree.ElementTree as ET
import xml.dom.minidom
from django.db.models import Count
from itertools import combinations
from dokumente.models import Vorgabe
@@ -121,3 +123,190 @@ def format_conflict_report(conflicts, verbose=False):
lines.append(f" Overlap starts: {overlap_start} (no end)")
return "\n".join(lines)
# XML Export utilities
def parse_markdown_table(markdown_content):
"""
Parse markdown table content and return XML element with <table><header><row><column> structure
"""
lines = [line.strip() for line in markdown_content.strip().split('\n') if line.strip()]
if not lines:
return None
# Create table element
table = ET.Element('table')
# Parse first row as header
header_row = [cell.strip() for cell in lines[0].split('|') if cell.strip()]
header = ET.SubElement(table, 'header')
for cell in header_row:
column = ET.SubElement(header, 'column')
column.text = cell
# Parse remaining rows (skip separator row if it exists)
for line in lines[2:] if len(lines) > 1 and all(c in '-| ' for c in lines[1]) else lines[1:]:
# Check if this is a separator row
if all(c in '-| ' for c in line):
continue
row = ET.SubElement(table, 'row')
row_cells = [cell.strip() for cell in line.split('|') if cell.strip()]
for cell in row_cells:
column = ET.SubElement(row, 'column')
column.text = cell
return table
def prettify_xml(xml_string):
"""
Prettify XML string with proper indentation
"""
dom = xml.dom.minidom.parseString(xml_string)
return dom.toprettyxml(indent=" ", encoding="UTF-8").decode('utf-8')
def build_dokument_xml_element(dokument, parent_element):
"""
Build XML element for a single Dokument and append it to parent_element.
Args:
dokument: Dokument instance (should be prefetched with related data)
parent_element: Parent XML element to append to
Returns:
The created document element
"""
doc_element = ET.SubElement(parent_element, 'Vorgabendokument')
ET.SubElement(doc_element, 'Typ').text = dokument.dokumententyp.name if dokument.dokumententyp else ""
ET.SubElement(doc_element, 'Nummer').text = dokument.nummer
ET.SubElement(doc_element, 'Name').text = dokument.name
autoren_element = ET.SubElement(doc_element, 'Autoren')
for autor in dokument.autoren.all():
ET.SubElement(autoren_element, 'Autor').text = autor.name
pruefende_element = ET.SubElement(doc_element, 'Pruefende')
for pruefender in dokument.pruefende.all():
ET.SubElement(pruefende_element, 'Pruefender').text = pruefender.name
gueltigkeit_element = ET.SubElement(doc_element, 'Gueltigkeit')
ET.SubElement(gueltigkeit_element, 'Von').text = dokument.gueltigkeit_von.strftime("%Y-%m-%d") if dokument.gueltigkeit_von else ""
ET.SubElement(gueltigkeit_element, 'Bis').text = dokument.gueltigkeit_bis.strftime("%Y-%m-%d") if dokument.gueltigkeit_bis else None
ET.SubElement(doc_element, 'SignaturCSO').text = dokument.signatur_cso
geltungsbereich_sections = dokument.geltungsbereich_set.all().order_by('order')
if geltungsbereich_sections:
geltungsbereich_element = ET.SubElement(doc_element, 'Geltungsbereich')
for gb in geltungsbereich_sections:
section_type = gb.abschnitttyp.abschnitttyp if gb.abschnitttyp else "text"
if section_type in ('tabelle', 'table'):
table = parse_markdown_table(gb.inhalt)
if table is not None:
abschnitt_element = ET.SubElement(geltungsbereich_element, 'Abschnitt')
abschnitt_element.set('typ', section_type)
abschnitt_element.append(table)
else:
abschnitt_element = ET.SubElement(geltungsbereich_element, 'Abschnitt')
abschnitt_element.set('typ', section_type)
abschnitt_element.text = gb.inhalt
einleitung_sections = dokument.einleitung_set.all().order_by('order')
if einleitung_sections:
einleitung_element = ET.SubElement(doc_element, 'Einleitung')
for ei in einleitung_sections:
section_type = ei.abschnitttyp.abschnitttyp if ei.abschnitttyp else "text"
if section_type in ('tabelle', 'table'):
table = parse_markdown_table(ei.inhalt)
if table is not None:
abschnitt_element = ET.SubElement(einleitung_element, 'Abschnitt')
abschnitt_element.set('typ', section_type)
abschnitt_element.append(table)
else:
abschnitt_element = ET.SubElement(einleitung_element, 'Abschnitt')
abschnitt_element.set('typ', section_type)
abschnitt_element.text = ei.inhalt
ET.SubElement(doc_element, 'Ziel').text = ""
ET.SubElement(doc_element, 'Grundlagen').text = ""
changelog_element = ET.SubElement(doc_element, 'Changelog')
for cl in dokument.changelog.all().order_by('-datum'):
entry = ET.SubElement(changelog_element, 'Eintrag')
ET.SubElement(entry, 'Datum').text = cl.datum.strftime("%Y-%m-%d")
autoren = ET.SubElement(entry, 'Autoren')
for autor in cl.autoren.all():
ET.SubElement(autoren, 'Autor').text = autor.name
ET.SubElement(entry, 'Aenderung').text = cl.aenderung
anhaenge_element = ET.SubElement(doc_element, 'Anhaenge')
ET.SubElement(anhaenge_element, 'Anhang').text = dokument.anhaenge
ET.SubElement(doc_element, 'Verantwortlich').text = "Information Security Management BIT"
ET.SubElement(doc_element, 'Klassifizierung').text = ""
glossar_element = ET.SubElement(doc_element, 'Glossar')
vorgaben_element = ET.SubElement(doc_element, 'Vorgaben')
for vorgabe in dokument.vorgaben.all().order_by('order'):
vorgabe_el = ET.SubElement(vorgaben_element, 'Vorgabe')
ET.SubElement(vorgabe_el, 'Nummer').text = str(vorgabe.nummer)
ET.SubElement(vorgabe_el, 'Titel').text = vorgabe.titel
ET.SubElement(vorgabe_el, 'Thema').text = vorgabe.thema.name if vorgabe.thema else ""
kurztext_sections = vorgabe.vorgabekurztext_set.all().order_by('order')
if kurztext_sections:
kurztext_element = ET.SubElement(vorgabe_el, 'Kurztext')
for kt in kurztext_sections:
section_type = kt.abschnitttyp.abschnitttyp if kt.abschnitttyp else "text"
if section_type in ('tabelle', 'table'):
table = parse_markdown_table(kt.inhalt)
if table is not None:
abschnitt = ET.SubElement(kurztext_element, 'Abschnitt')
abschnitt.set('typ', section_type)
abschnitt.append(table)
else:
abschnitt = ET.SubElement(kurztext_element, 'Abschnitt')
abschnitt.set('typ', section_type)
abschnitt.text = kt.inhalt
langtext_sections = vorgabe.vorgabelangtext_set.all().order_by('order')
if langtext_sections:
langtext_element = ET.SubElement(vorgabe_el, 'Langtext')
for lt in langtext_sections:
section_type = lt.abschnitttyp.abschnitttyp if lt.abschnitttyp else "text"
if section_type in ('tabelle', 'table'):
table = parse_markdown_table(lt.inhalt)
if table is not None:
abschnitt = ET.SubElement(langtext_element, 'Abschnitt')
abschnitt.set('typ', section_type)
abschnitt.append(table)
else:
abschnitt = ET.SubElement(langtext_element, 'Abschnitt')
abschnitt.set('typ', section_type)
abschnitt.text = lt.inhalt
referenz_element = ET.SubElement(vorgabe_el, 'Referenzen')
for ref in vorgabe.referenzen.all():
ref_text = f"{ref.name_nummer}: {ref.name_text}" if ref.name_text else ref.name_nummer
ET.SubElement(referenz_element, 'Referenz').text = ref_text
vorgabe_gueltigkeit = ET.SubElement(vorgabe_el, 'Gueltigkeit')
ET.SubElement(vorgabe_gueltigkeit, 'Von').text = vorgabe.gueltigkeit_von.strftime("%Y-%m-%d") if vorgabe.gueltigkeit_von else ""
ET.SubElement(vorgabe_gueltigkeit, 'Bis').text = vorgabe.gueltigkeit_bis.strftime("%Y-%m-%d") if vorgabe.gueltigkeit_bis else None
checklistenfragen_element = ET.SubElement(vorgabe_el, 'Checklistenfragen')
for cf in vorgabe.checklistenfragen.all():
ET.SubElement(checklistenfragen_element, 'Frage').text = cf.frage
stichworte_element = ET.SubElement(vorgabe_el, 'Stichworte')
for stw in vorgabe.stichworte.all():
ET.SubElement(stichworte_element, 'Stichwort').text = stw.stichwort
return doc_element

View File

@@ -1,13 +1,15 @@
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import JsonResponse
from django.http import JsonResponse, HttpResponse
from django.core.serializers.json import DjangoJSONEncoder
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from django.utils.html import escape, mark_safe
from django.utils.safestring import SafeString
import json
import xml.etree.ElementTree as ET
from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage, VorgabeComment
from .utils import build_dokument_xml_element, prettify_xml
from abschnitte.utils import render_textabschnitte
from datetime import date
@@ -254,6 +256,37 @@ def standard_json(request, nummer):
return JsonResponse(doc_data, json_dumps_params={'indent': 2, 'ensure_ascii': False}, encoder=DjangoJSONEncoder)
def standard_xml(request, nummer):
"""
Export a single Dokument as XML
"""
# 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
)
# Create a temporary root element to build the document
root = ET.Element('root')
build_dokument_xml_element(dokument, root)
# Get the actual document element (first child of root)
doc_element = root[0]
xml_str = ET.tostring(doc_element, encoding='unicode', method='xml')
xml_output = prettify_xml(xml_str)
response = HttpResponse(xml_output, content_type='application/xml; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="{dokument.nummer}.xml"'
return response
@login_required
def get_vorgabe_comments(request, vorgabe_id):
"""Get comments for a specific Vorgabe"""

View File

@@ -1,19 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: data-loader
namespace: vorgabenui
spec:
restartPolicy: Never
containers:
- name: loader
image: adebaumann/vgui-preloader:0.5
command: ["sh","-c","cp -v --debug --update=none /preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; exit 0"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: django-data-pvc

Binary file not shown.

View File

@@ -1,68 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: django
namespace: vorgabenui
spec:
replicas: 10
selector:
matchLabels:
app: django
template:
metadata:
labels:
app: django
spec:
securityContext:
fsGroup: 999
fsGroupChangePolicy: "OnRootMismatch"
initContainers:
- name: loader
image: adebaumann/vgui-preloader:0.5
command: [ "sh","-c","cp -v --debug --update=none /preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; exit 0" ]
volumeMounts:
- name: data
mountPath: /data
containers:
- name: web
image: docker.io/adebaumann/vui:0.918
imagePullPolicy: Always
ports:
- containerPort: 8000
volumeMounts:
- name: data
mountPath: /app/data
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: django-data-pvc
---
apiVersion: v1
kind: Service
metadata:
name: django
namespace: vorgabenui
spec:
type: ClusterIP
selector:
app: django
ports:
- port: 8000
targetPort: 8000

View File

@@ -1,60 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: kroki
namespace: vorgabenui
spec:
replicas: 1
selector:
matchLabels:
app: kroki
template:
metadata:
labels:
app: kroki
spec:
containers:
- name: kroki
image: docker.io/yuzutech/kroki:latest
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
- name: mermaid
image: docker.io/yuzutech/kroki-mermaid:latest
ports:
- containerPort: 8002
- name: bpmn
image: docker.io/yuzutech/kroki-bpmn:latest
ports:
- containerPort: 8003
- name: excalidraw
image: docker.io/yuzutech/kroki-excalidraw:latest
ports:
- containerPort: 8004
---
apiVersion: v1
kind: Service
metadata:
name: svckroki
namespace: vorgabenui
spec:
selector:
app: kroki
ports:
- port: 8000
targetPort: 8000

View File

@@ -1,19 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: django
namespace: vorgabenui
annotations:
traefik.ingress.kubernetes.io/router.middlewares: "vorgabenui-vorgabenui-rewrite@kubernetescrd"
spec:
rules:
- host: vorgabenui.adebaumann.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: django
port:
number: 8000

View File

@@ -1,18 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: django
namespace: vorgabenui
spec:
ingressClassName: nginx
rules:
- host: vorgabenui.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: django
port:
number: 8000

View File

@@ -1,15 +0,0 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: django-data-pv
namespace: vorgabenui
spec:
capacity:
storage: 2Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: nfs
nfs:
server: 192.168.17.199
path: /mnt/user/vorgabenui

View File

@@ -1,8 +0,0 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs
provisioner: kubernetes.io/no-provisioner
allowVolumeExpansion: true
reclaimPolicy: Retain
volumeBindingMode: Immediate

View File

@@ -1,13 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: django-data-pvc
namespace: vorgabenui
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs
resources:
requests:
storage: 2Gi

View File

@@ -1,9 +0,0 @@
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: vorgabenui-rewrite
namespace: vorgabenui
spec:
stripPrefix:
prefixes:
- "/"

16
pages/templates/400.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Ungültige Anfrage{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="alert alert-warning">
<h2><i class="icon icon--alert"></i> Ungültige Anfrage (400)</h2>
<p>Ihre Anfrage konnte nicht verarbeitet werden.</p>
<p>Bitte überprüfen Sie die eingegebenen Daten und versuchen Sie es erneut.</p>
<p><a href="/" class="btn btn-primary">Zur Startseite</a></p>
</div>
</div>
</div>
{% endblock %}

21
pages/templates/403.html Normal file
View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Zugriff verweigert{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="alert alert-warning">
<h2><i class="icon icon--alert"></i> Zugriff verweigert (403)</h2>
<p>Sie haben keine Berechtigung, auf diese Seite zuzugreifen.</p>
<p>Bitte melden Sie sich an oder wenden Sie sich an den Administrator.</p>
<p>
<a href="/" class="btn btn-primary">Zur Startseite</a>
{% if not user.is_authenticated %}
<a href="{% url 'login' %}" class="btn btn-secondary">Anmelden</a>
{% endif %}
</p>
</div>
</div>
</div>
{% endblock %}

21
pages/templates/404.html Normal file
View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Seite nicht gefunden{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="alert alert-danger">
<h2><i class="icon icon--alert"></i> Seite nicht gefunden (404)</h2>
<p>Die gewünschte Seite konnte nicht gefunden werden.</p>
<p>Mögliche Gründe:</p>
<ul>
<li>Sie haben eine falsche URL eingegeben</li>
<li>Die Seite wurde verschoben oder gelöscht</li>
<li>Sie haben keine Berechtigung für diese Seite</li>
</ul>
<p><a href="/" class="btn btn-primary">Zur Startseite</a></p>
</div>
</div>
</div>
{% endblock %}

16
pages/templates/500.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Serverfehler{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="alert alert-danger">
<h2><i class="icon icon--alert"></i> Serverfehler (500)</h2>
<p>Bei der Verarbeitung Ihrer Anfrage ist ein interner Fehler aufgetreten.</p>
<p>Der Administrator wurde über dieses Problem informiert.</p>
<p><a href="/" class="btn btn-primary">Zur Startseite</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -219,7 +219,7 @@
</p>
</div>
<div class="col-sm-6 text-right">
<p class="text-muted">Version {{ version|default:"0.973" }}</p>
<p class="text-muted">Version {{ version|default:"0.983" }}</p>
</div>
</div>
</div>

View File

@@ -69,3 +69,15 @@ def search(request):
return render(request,"results.html",{"suchbegriff":safe_search_term,"resultat":result})
def custom_400(request, exception):
return render(request, '400.html', status=400)
def custom_403(request, exception):
return render(request, '403.html', status=403)
def custom_404(request, exception):
return render(request, '404.html', status=404)
def custom_500(request):
return render(request, '500.html', status=500)

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0.1 on 2026-01-20 08:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('referenzen', '0003_alter_referenzerklaerung_options'),
]
operations = [
migrations.AlterModelOptions(
name='referenz',
options={'verbose_name': 'Referenz', 'verbose_name_plural': 'Referenzen'},
),
]

View File

@@ -25,6 +25,7 @@ class Referenz(MPTTModel):
class Meta:
verbose_name_plural="Referenzen"
verbose_name="Referenz"
class Referenzerklaerung (Textabschnitt):
erklaerung = models.ForeignKey(Referenz,on_delete=models.CASCADE)

View File

@@ -9,7 +9,7 @@ def tree(request):
def detail(request, refid):
referenz_item = Referenz.objects.get(id=refid)
referenz_item = Referenz.objects.get_object_or_404(id=refid)
referenz_item.erklaerung = render_textabschnitte(referenz_item.referenzerklaerung_set.order_by("order"))
referenz_item.children = list(referenz_item.get_descendants(include_self=True))
for child in referenz_item.children:

View File

@@ -1,36 +1,44 @@
appdirs==1.4.4
asgiref==3.8.1
blessed==1.21.0
certifi==2025.8.3
charset-normalizer==3.4.3
asgiref==3.11.0
bleach==6.3.0
blessed==1.27.0
certifi==2026.1.4
charset-normalizer==3.4.4
coverage==7.13.1
curtsies==0.4.3
cwcwidth==0.1.10
Django==5.2.9
django-admin-sortable2==2.2.8
cwcwidth==0.1.12
Django==6.0.1
django-admin-sortable2==2.3
django-js-asset==3.1.2
django-mptt==0.17.0
django-mptt-admin==2.8.0
django-nested-admin==4.1.1
django-mptt==0.18.0
django-mptt-admin==2.9.0
django-nested-admin==4.1.6
django-nested-inline==0.4.6
django-revproxy==0.13.0
greenlet==3.2.4
greenlet==3.3.0
gunicorn==23.0.0
idna==3.10
idna==3.11
jedi==0.19.2
Markdown==3.8.2
jproperties==2.1.2
Markdown==3.10
packaging==25.0
parsedatetime==2.6
parso==0.8.4
parso==0.8.5
pep8==1.7.1
prompt_toolkit==3.0.51
prompt_toolkit==3.0.52
pyfakefs==5.9.3
Pygments==2.19.2
pysonar==1.2.1.3951
python-dateutil==2.9.0.post0
python-monkey-business==1.1.0
pyxdg==0.28
PyYAML==6.0.3
requests==2.32.5
responses==0.25.8
six==1.17.0
sqlparse==0.5.3
urllib3==2.6.0
wcwidth==0.2.13
bleach==6.1.0
coverage==7.6.1
sqlparse==0.5.5
tomli==2.2.1
urllib3==2.6.3
wcwidth==0.2.14
webencodings==0.5.1
whitenoise==6.11.0

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0.1 on 2026-01-20 08:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('rollen', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='rolle',
options={'verbose_name': 'Rolle (für Relevanz)', 'verbose_name_plural': 'Rolleni (für Relevanz)'},
),
]

View File

@@ -9,6 +9,7 @@ class Rolle(models.Model):
return self.name
class Meta:
verbose_name_plural="Rollen"
verbose_name="Rolle"
class RollenBeschreibung(Textabschnitt):
abschnitt=models.ForeignKey(Rolle,on_delete=models.CASCADE)

View File

@@ -0,0 +1,216 @@
#!/bin/bash
# deploy-argocd-configmap.sh
# Script to deploy Django ConfigMap to vorgabenui namespace for ArgoCD
set -euo pipefail
# ArgoCD-specific configuration (hardcoded for consistency)
NAMESPACE="vorgabenui"
CONFIGMAP_NAME="django-config"
SCRIPT_DIR="$(dirname "$0")"
ARGOCD_DIR="$SCRIPT_DIR/../argocd"
CONFIGMAP_FILE="$ARGOCD_DIR/configmap.yaml"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_step() {
echo -e "${BLUE}[STEP]${NC} $1"
}
# Function to check if kubectl is available
check_kubectl() {
if ! command -v kubectl &> /dev/null; then
log_error "kubectl is not installed or not in PATH"
exit 1
fi
}
# Function to check if configmap file exists
check_configmap_file() {
if [ ! -f "$CONFIGMAP_FILE" ]; then
log_error "ConfigMap file not found: $CONFIGMAP_FILE"
log_error "Expected ArgoCD ConfigMap file at: $CONFIGMAP_FILE"
exit 1
fi
}
# Function to deploy the configmap
deploy_configmap() {
log_step "Deploying ConfigMap '$CONFIGMAP_NAME' to namespace '$NAMESPACE'..."
kubectl apply -f "$CONFIGMAP_FILE"
if [ $? -eq 0 ]; then
log_info "Successfully deployed ConfigMap '$CONFIGMAP_NAME'"
return 0
else
log_error "Failed to deploy ConfigMap '$CONFIGMAP_NAME'"
return 1
fi
}
# Function to verify the configmap
verify_configmap() {
log_step "Verifying ConfigMap deployment..."
if kubectl get configmap "$CONFIGMAP_NAME" --namespace="$NAMESPACE" &> /dev/null; then
log_info "✅ ConfigMap '$CONFIGMAP_NAME' exists in namespace '$NAMESPACE'"
echo ""
log_info "ConfigMap details:"
kubectl describe configmap "$CONFIGMAP_NAME" --namespace="$NAMESPACE"
echo ""
log_info "ConfigMap data:"
kubectl get configmap "$CONFIGMAP_NAME" --namespace="$NAMESPACE" -o yaml | grep -A 20 "^data:"
return 0
else
log_error "❌ ConfigMap '$CONFIGMAP_NAME' not found in namespace '$NAMESPACE'"
return 1
fi
}
# Function to show usage
show_usage() {
echo "ArgoCD ConfigMap Deployment Script for VorgabenUI"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "This script deploys Django configuration to the vorgabenui namespace for ArgoCD."
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --verify-only Only verify existing ConfigMap, don't deploy"
echo " --dry-run Show what would be deployed without applying"
echo ""
echo "Configuration (hardcoded for ArgoCD):"
echo " Namespace: $NAMESPACE"
echo " ConfigMap Name: $CONFIGMAP_NAME"
echo " ConfigMap File: $CONFIGMAP_FILE"
echo ""
echo "Examples:"
echo " $0 # Deploy ConfigMap"
echo " $0 --verify-only # Verify existing ConfigMap"
echo " $0 --dry-run # Preview deployment"
echo ""
echo "Note: Run this before deploying the ArgoCD deployment to ensure configuration is available."
}
# Parse command line arguments
VERIFY_ONLY=false
DRY_RUN=false
while [[ $# -gt 0 ]]; do
case $1 in
--verify-only)
VERIFY_ONLY=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
-h|--help)
show_usage
exit 0
;;
*)
log_error "Unknown option: $1"
show_usage
exit 1
;;
esac
done
# Main execution
main() {
echo ""
log_info "🚀 ArgoCD Django ConfigMap Deployment Script"
log_info "============================================"
echo ""
log_info "Target Configuration:"
log_info " Namespace: $NAMESPACE"
log_info " ConfigMap Name: $CONFIGMAP_NAME"
log_info " ConfigMap File: $CONFIGMAP_FILE"
echo ""
# Perform checks
log_step "Performing pre-flight checks..."
check_kubectl
check_configmap_file
log_info "✅ All pre-flight checks passed"
echo ""
# Verify-only mode
if [ "$VERIFY_ONLY" = true ]; then
log_info "🔍 Verify-only mode - checking existing ConfigMap"
verify_configmap
exit $?
fi
# Dry-run mode
if [ "$DRY_RUN" = true ]; then
log_info "🔍 Dry-run mode - showing what would be deployed:"
echo ""
log_info "ConfigMap content that would be deployed:"
cat "$CONFIGMAP_FILE"
echo ""
log_info "Would run: kubectl apply -f $CONFIGMAP_FILE"
echo ""
log_info "Run without --dry-run to execute the deployment"
exit 0
fi
# Create namespace if it doesn't exist
if ! kubectl get namespace "$NAMESPACE" &> /dev/null; then
log_warn "Namespace '$NAMESPACE' does not exist, creating..."
kubectl create namespace "$NAMESPACE"
log_info "✅ Created namespace '$NAMESPACE'"
fi
# Deploy the ConfigMap
if deploy_configmap; then
echo ""
# Verify deployment
verify_configmap
echo ""
log_info "🎉 ConfigMap deployment completed successfully!"
echo ""
log_info "📋 Next steps:"
log_info "1. Deploy the secret (if not already done):"
echo " ./scripts/deploy-argocd-secret.sh"
echo ""
log_info "2. Apply the updated deployment:"
echo " kubectl apply -f argocd/deployment.yaml"
echo ""
log_info "3. Verify Django pods start with proper configuration"
echo ""
else
log_error "ConfigMap deployment failed"
exit 1
fi
}
# Run main function
main

311
scripts/deploy-argocd-secret.sh Executable file
View File

@@ -0,0 +1,311 @@
#!/bin/bash
# deploy-argocd-secret.sh
# ArgoCD-specific script to generate and deploy Django SECRET_KEY to vorgabenui namespace
set -euo pipefail
# ArgoCD-specific configuration (hardcoded for consistency)
NAMESPACE="vorgabenui"
SECRET_NAME="vorgabenui-secrets"
SECRET_KEY_NAME="vorgabenui_secret"
SCRIPT_DIR="$(dirname "$0")"
ARGOCD_DIR="$SCRIPT_DIR/../argocd"
TEMPLATES_DIR="$SCRIPT_DIR/../templates"
SECRET_TEMPLATE="$TEMPLATES_DIR/secret.yaml"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_step() {
echo -e "${BLUE}[STEP]${NC} $1"
}
# Function to generate a secure Django SECRET_KEY
generate_secret_key() {
# Generate a 50-character secret key using Python (same as Django's default)
python3 -c "
import secrets
import string
# Django-style secret key generation
chars = string.ascii_letters + string.digits + '!@#$%^&*(-_=+)'
print(''.join(secrets.choice(chars) for _ in range(50)))
"
}
# Function to check if kubectl is available
check_kubectl() {
if ! command -v kubectl &> /dev/null; then
log_error "kubectl is not installed or not in PATH"
exit 1
fi
}
# Function to check if Python3 is available
check_python() {
if ! command -v python3 &> /dev/null; then
log_error "python3 is not installed or not in PATH"
exit 1
fi
}
# Function to check if secret template exists
check_template() {
if [ ! -f "$SECRET_TEMPLATE" ]; then
# Fallback to argocd directory if templates directory doesn't exist
FALLBACK_TEMPLATE="$ARGOCD_DIR/secret.yaml"
if [ -f "$FALLBACK_TEMPLATE" ]; then
SECRET_TEMPLATE="$FALLBACK_TEMPLATE"
log_warn "Using fallback template: $SECRET_TEMPLATE"
else
log_error "Secret template not found at either:"
log_error " Primary: $TEMPLATES_DIR/secret.yaml"
log_error " Fallback: $FALLBACK_TEMPLATE"
exit 1
fi
fi
}
# Function to create the secret
create_secret() {
local secret_key="$1"
log_step "Creating Kubernetes secret '$SECRET_NAME' in namespace '$NAMESPACE'..."
# Create the secret directly with kubectl (this will create or update)
kubectl create secret generic "$SECRET_NAME" \
--from-literal="$SECRET_KEY_NAME=$secret_key" \
--namespace="$NAMESPACE" \
--dry-run=client -o yaml | kubectl apply -f -
if [ $? -eq 0 ]; then
log_info "Successfully created/updated secret '$SECRET_NAME'"
return 0
else
log_error "Failed to create/update secret '$SECRET_NAME'"
return 1
fi
}
# Function to verify the secret
verify_secret() {
log_step "Verifying secret deployment..."
if kubectl get secret "$SECRET_NAME" --namespace="$NAMESPACE" &> /dev/null; then
log_info "✅ Secret '$SECRET_NAME' exists in namespace '$NAMESPACE'"
# Show secret metadata (without revealing the actual key)
echo ""
log_info "Secret details:"
kubectl describe secret "$SECRET_NAME" --namespace="$NAMESPACE" | grep -E "^(Name|Namespace|Type|Data)"
# Verify the key exists in the secret
if kubectl get secret "$SECRET_NAME" --namespace="$NAMESPACE" -o jsonpath="{.data.$SECRET_KEY_NAME}" &> /dev/null; then
log_info "✅ Secret key '$SECRET_KEY_NAME' is present in the secret"
return 0
else
log_error "❌ Secret key '$SECRET_KEY_NAME' not found in secret"
return 1
fi
else
log_error "❌ Secret '$SECRET_NAME' not found in namespace '$NAMESPACE'"
return 1
fi
}
# Function to test secret in pod (if deployment exists)
test_secret_in_pod() {
log_step "Testing secret accessibility in Django deployment..."
# Check if Django deployment exists
if kubectl get deployment django --namespace="$NAMESPACE" &> /dev/null; then
log_info "Django deployment found, testing secret access..."
# Try to get the secret value from a pod (this will fail if env var not configured)
local pod_name
pod_name=$(kubectl get pods -l app=django --namespace="$NAMESPACE" -o jsonpath="{.items[0].metadata.name}" 2>/dev/null)
if [ -n "$pod_name" ] && [ "$pod_name" != "" ]; then
log_info "Testing secret in pod: $pod_name"
if kubectl exec "$pod_name" --namespace="$NAMESPACE" -- printenv VORGABENUI_SECRET &> /dev/null; then
log_info "✅ VORGABENUI_SECRET environment variable is accessible in pod"
else
log_warn "⚠️ VORGABENUI_SECRET environment variable not found in pod"
log_warn " This is expected if the deployment hasn't been updated yet"
fi
else
log_warn "⚠️ No running Django pods found"
fi
else
log_info "Django deployment not found - secret will be available when deployment is updated"
fi
}
# Function to show usage
show_usage() {
echo "ArgoCD Secret Deployment Script for VorgabenUI"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "This script deploys Django SECRET_KEY to the vorgabenui namespace for ArgoCD."
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --verify-only Only verify existing secret, don't create new one"
echo " --dry-run Show what would be done without making changes"
echo ""
echo "Configuration (hardcoded for ArgoCD):"
echo " Namespace: $NAMESPACE"
echo " Secret Name: $SECRET_NAME"
echo " Secret Key: $SECRET_KEY_NAME"
echo " Template: $SECRET_TEMPLATE"
echo ""
echo "Examples:"
echo " $0 # Generate and deploy new secret"
echo " $0 --verify-only # Verify existing secret"
echo " $0 --dry-run # Preview changes"
echo ""
echo "After running this script, update argocd/deployment.yaml to reference the secret."
}
# Parse command line arguments
VERIFY_ONLY=false
DRY_RUN=false
while [[ $# -gt 0 ]]; do
case $1 in
--verify-only)
VERIFY_ONLY=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
-h|--help)
show_usage
exit 0
;;
*)
log_error "Unknown option: $1"
show_usage
exit 1
;;
esac
done
# Main execution
main() {
echo ""
log_info "🚀 ArgoCD Django SECRET_KEY Deployment Script"
log_info "============================================="
echo ""
log_info "Target Configuration:"
log_info " Namespace: $NAMESPACE"
log_info " Secret Name: $SECRET_NAME"
log_info " Secret Key Name: $SECRET_KEY_NAME"
echo ""
# Perform checks
log_step "Performing pre-flight checks..."
check_kubectl
check_python
check_template
log_info "✅ All pre-flight checks passed"
echo ""
# Verify-only mode
if [ "$VERIFY_ONLY" = true ]; then
log_info "🔍 Verify-only mode - checking existing secret"
verify_secret
test_secret_in_pod
exit $?
fi
# Generate new secret key
log_step "Generating new Django SECRET_KEY..."
SECRET_KEY=$(generate_secret_key)
if [ -z "$SECRET_KEY" ]; then
log_error "Failed to generate secret key"
exit 1
fi
log_info "✅ Generated secret key (first 10 chars): ${SECRET_KEY:0:10}..."
echo ""
# Dry-run mode
if [ "$DRY_RUN" = true ]; then
log_info "🔍 Dry-run mode - showing what would be done:"
echo ""
log_info "Would create secret with the following command:"
echo " kubectl create secret generic $SECRET_NAME \\"
echo " --from-literal=$SECRET_KEY_NAME='[GENERATED_KEY]' \\"
echo " --namespace=$NAMESPACE \\"
echo " --dry-run=client -o yaml | kubectl apply -f -"
echo ""
log_info "Secret key would be: ${SECRET_KEY:0:10}...${SECRET_KEY: -5}"
echo ""
log_info "Run without --dry-run to execute the deployment"
exit 0
fi
# Create namespace if it doesn't exist
if ! kubectl get namespace "$NAMESPACE" &> /dev/null; then
log_warn "Namespace '$NAMESPACE' does not exist, creating..."
kubectl create namespace "$NAMESPACE"
log_info "✅ Created namespace '$NAMESPACE'"
fi
# Create the secret
if create_secret "$SECRET_KEY"; then
echo ""
# Verify deployment
verify_secret
echo ""
test_secret_in_pod
echo ""
log_info "🎉 Secret deployment completed successfully!"
echo ""
log_info "📋 Next steps:"
log_info "1. Update argocd/deployment.yaml to include environment variable:"
echo ""
echo " env:"
echo " - name: VORGABENUI_SECRET"
echo " valueFrom:"
echo " secretKeyRef:"
echo " name: $SECRET_NAME"
echo " key: $SECRET_KEY_NAME"
echo ""
log_info "2. Apply the updated deployment:"
echo " kubectl apply -f argocd/deployment.yaml"
echo ""
log_info "3. Verify Django pods restart and pick up the new secret"
echo ""
else
log_error "Secret deployment failed"
exit 1
fi
}
# Run main function
main

45
scripts/deploy_secret.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Generate and deploy Django secret key to Kubernetes
NAMESPACE="vorgabenui"
SECRET_NAME="django-secret"
SECRET_FILE="templates/secret.yaml"
# Check if secret file exists
if [ ! -f "$SECRET_FILE" ]; then
echo "Error: $SECRET_FILE not found"
exit 1
fi
# Generate random secret key
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")
# Create temporary secret file with generated key
TEMP_SECRET_FILE=$(mktemp)
cat "$SECRET_FILE" | sed "s/CHANGE_ME_TO_RANDOM_STRING/$SECRET_KEY/g" > "$TEMP_SECRET_FILE"
# Check if secret already exists
if kubectl get secret "$SECRET_NAME" -n "$NAMESPACE" &>/dev/null; then
echo "Secret $SECRET_NAME already exists in namespace $NAMESPACE"
read -p "Do you want to replace it? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted"
rm "$TEMP_SECRET_FILE"
exit 0
fi
kubectl apply -f "$TEMP_SECRET_FILE"
echo "Secret updated successfully"
else
kubectl apply -f "$TEMP_SECRET_FILE"
echo "Secret created successfully"
fi
# Clean up
rm "$TEMP_SECRET_FILE"
echo ""
echo "Secret deployed:"
echo " Name: $SECRET_NAME"
echo " Namespace: $NAMESPACE"
echo " Key: secret-key"

45
scripts/full_deploy.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Full deployment script - bumps both container versions by 0.001 and copies database
DEPLOYMENT_FILE="argocd/deployment.yaml"
DB_SOURCE="data/db.sqlite3"
DB_DEST="data-loader/preload.sqlite3"
# Check if deployment file exists
if [ ! -f "$DEPLOYMENT_FILE" ]; then
echo "Error: $DEPLOYMENT_FILE not found"
exit 1
fi
# Check if source database exists
if [ ! -f "$DB_SOURCE" ]; then
echo "Error: $DB_SOURCE not found"
exit 1
fi
# Extract current version of data-loader
LOADER_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/vgui-data-loader:[0-9]" "$DEPLOYMENT_FILE" | sed -E 's/.*:([0-9.]+)/\1/')
# Extract current version of main container
MAIN_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/vgui:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
if [ -z "$LOADER_VERSION" ] || [ -z "$MAIN_VERSION" ]; then
echo "Error: Could not find current versions"
exit 1
fi
# Calculate new versions (add 0.001), preserve leading zero
NEW_LOADER_VERSION=$(echo "$LOADER_VERSION + 0.001" | bc | sed 's/^\./0./')
NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
# Update the deployment file
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE"
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE"
# Copy database
cp "$DB_SOURCE" "$DB_DEST"
echo "Full deployment prepared:"
echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION"
echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION"
echo " Database copied to $DB_DEST"

27
scripts/partial_deploy.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Partial deployment script - bumps main container version by 0.001
DEPLOYMENT_FILE="argocd/deployment.yaml"
# Check if file exists
if [ ! -f "$DEPLOYMENT_FILE" ]; then
echo "Error: $DEPLOYMENT_FILE not found"
exit 1
fi
# Extract current version of main container (labhelper, not labhelper-data-loader)
CURRENT_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/vui:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
if [ -z "$CURRENT_VERSION" ]; then
echo "Error: Could not find current version"
exit 1
fi
# Calculate new version (add 0.001), preserve leading zero
NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
# Update the deployment file (only the main container, not the data-loader)
sed -i "s|image: git.baumann.gr/adebaumann/vui:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/vui:$NEW_VERSION|" "$DEPLOYMENT_FILE"
echo "Partial deployment prepared:"
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0.1 on 2026-01-20 08:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stichworte', '0003_alter_stichworterklaerung_options'),
]
operations = [
migrations.AlterModelOptions(
name='stichwort',
options={'verbose_name': 'Stichwort', 'verbose_name_plural': 'Stichworte'},
),
]

View File

@@ -9,6 +9,7 @@ class Stichwort(models.Model):
class Meta:
verbose_name_plural="Stichworte"
verbose_name = "Stichwort"
class Stichworterklaerung (Textabschnitt):
erklaerung = models.ForeignKey(Stichwort,on_delete=models.CASCADE)

28
templates/configmap.yaml Normal file
View File

@@ -0,0 +1,28 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: django-config
namespace: vorgabenui
data:
# Django Configuration
DEBUG: "false"
DJANGO_ALLOWED_HOSTS: "vorgabenportal.knowyoursecurity.com,localhost,127.0.0.1"
DJANGO_SETTINGS_MODULE: "VorgabenUI.settings"
# Application Configuration
LANGUAGE_CODE: "de-ch"
TIME_ZONE: "UTC"
# Static and Media Configuration
STATIC_URL: "/static/"
MEDIA_URL: "/media/"
# Database Configuration (for future use)
# DATABASE_ENGINE: "django.db.backends.sqlite3"
# DATABASE_NAME: "/app/data/db.sqlite3"
# Security Configuration
# CSRF_TRUSTED_ORIGINS: "https://vorgabenportal.knowyoursecurity.com"
# Performance Configuration
# DATA_UPLOAD_MAX_NUMBER_FIELDS: "10250"

10
templates/secret.yaml Normal file
View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: vorgabenui-secrets
namespace: vorgabenui
type: Opaque
data:
# Base64 encoded SECRET_KEY - populated by deployment script
# This is a TEMPLATE FILE in templates/ directory - not deployed by ArgoCD
vorgabenui_secret: ""

0
test Normal file
View File