Compare commits
98 Commits
feature/ob
...
upgrade/dj
| Author | SHA1 | Date | |
|---|---|---|---|
| 996584ef68 | |||
| 18fac6e8b9 | |||
| 492f8b4e90 | |||
| e86e3c19b5 | |||
| 938424a02e | |||
| b9e1a06e09 | |||
| 1a0c74bfa2 | |||
| 82455358ff | |||
| 713798352d | |||
| 0e8e2da169 | |||
| e8f34f7fa5 | |||
| 67d4087e3a | |||
| ffda7ca601 | |||
| 9d0a838238 | |||
| b0725fb385 | |||
| c77e8c0432 | |||
| 51969141e7 | |||
| b7f50ce30f | |||
| d3d0298ad1 | |||
| 29c1ad1dcf | |||
| 4504e8a2a5 | |||
| 502dd85efb | |||
| 9e6e2b5a03 | |||
| c492b7bda6 | |||
| 0d7e63d3a2 | |||
| 0866e604bc | |||
| 753c00bc45 | |||
| 74d2f15d6a | |||
| 615908e569 | |||
| 55d467ee58 | |||
| 5a1df7345d | |||
| e3c5f6a9d7 | |||
| a26290fc92 | |||
| 08d94a9269 | |||
| 6f8f273344 | |||
| f96226170b | |||
| 0783033c70 | |||
| 35fbfdccec | |||
| 1196d3cdd2 | |||
| df67948efc | |||
| a78f53f58e | |||
| 2c39db104e | |||
| ad17b394a3 | |||
| 745ce4fabc | |||
| b6fbe750a2 | |||
| 89d3eec5fb | |||
| cd4783efc4 | |||
| 9efef2c5e2 | |||
| 09010a117e | |||
| 8ea0937ea4 | |||
| 5330493c85 | |||
| 9e6e9e9830 | |||
| f311050412 | |||
| 492b3c5a20 | |||
| a81b6eb9d5 | |||
| f6be6d6a02 | |||
| c8d3ef4631 | |||
| 46912cff8c | |||
| 1af50c45ff | |||
| 40551094e6 | |||
| 4297c2d8bf | |||
| 3a89f6d871 | |||
| 07ba717de9 | |||
| 048105ef27 | |||
| b579f5fb42 | |||
| db9bd92036 | |||
| 7e89ffb6f1 | |||
| dd6d0fae46 | |||
| e5202d9b2b | |||
| 5535684a45 | |||
| f933b7d99a | |||
| fd729b3019 | |||
| e1c1eafb39 | |||
| 1b016c49f2 | |||
| 4376069b11 | |||
| c285ae81af | |||
| 5bfe4866a4 | |||
| f7799675d5 | |||
| c125427b8d | |||
| a14a80f7bd | |||
| 477143b3ff | |||
| fc404f6755 | |||
| fe7c55eceb | |||
| bb01174bd2 | |||
| 38ce55d8fd | |||
| d439741339 | |||
| bc75cac6cd | |||
| 47c264e8e1 | |||
| 4d0ed116dd | |||
| ceb6e13447 | |||
| 7e9059a9aa | |||
| ccf31e4ef4 | |||
| 94e047c7ff | |||
| 57f2210c77 | |||
| 1745596d14 | |||
| e923624aec | |||
| 3649878b7d | |||
| 179e7d41b3 |
22
.argocdignore
Normal file
22
.argocdignore
Normal 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/
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
chmod +x /usr/local/bin/yq
|
||||
yq --version
|
||||
|
||||
- name: Read ${{ matrix.description }} image from deployment (old vs new)
|
||||
- name: Read ${{ matrix.description }} image from deployment
|
||||
id: img
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -211,32 +211,60 @@ jobs:
|
||||
echo "ERROR: Found $ctype \"$cname\" image repo is \"$new_repo\" but expected \"$expected_repo\""
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "${old_image:-}" ]; then
|
||||
old_tag="${old_image##*:}"
|
||||
else
|
||||
old_tag=""
|
||||
fi
|
||||
|
||||
registry="$(echo "$new_repo" | awk -F/ '{print $1}')"
|
||||
|
||||
{
|
||||
echo "changed=$([ "$old_tag" != "$new_tag" ] && echo true || echo false)"
|
||||
echo "new_image=$new_image"
|
||||
echo "new_repo=$new_repo"
|
||||
echo "new_tag=$new_tag"
|
||||
echo "registry=$registry"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Skip if tag unchanged
|
||||
if: steps.img.outputs.changed != 'true'
|
||||
run: echo "${{ matrix.description }} image tag unchanged; skipping build."
|
||||
- name: Check if image exists on registry
|
||||
id: check_image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
new_repo="${{ steps.img.outputs.new_repo }}"
|
||||
new_tag="${{ steps.img.outputs.new_tag }}"
|
||||
registry_user="${{ secrets.REGISTRY_USER }}"
|
||||
registry_password="${{ secrets.REGISTRY_PASSWORD }}"
|
||||
|
||||
# Extract registry host and image name
|
||||
registry_host=$(echo "$new_repo" | cut -d/ -f1)
|
||||
image_path=$(echo "$new_repo" | cut -d/ -f2-)
|
||||
|
||||
echo "Checking if $new_repo:$new_tag exists on registry $registry_host"
|
||||
|
||||
# Use Docker Registry API v2 to check manifest
|
||||
# Format: https://registry/v2/{image_path}/manifests/{tag}
|
||||
manifest_url="https://${registry_host}/v2/${image_path}/manifests/${new_tag}"
|
||||
|
||||
# Check with authentication
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-u "${registry_user}:${registry_password}" \
|
||||
-H "Accept: application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json" \
|
||||
"$manifest_url" || echo "000")
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo "Image already exists on registry (HTTP $http_code)"
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Image does not exist on registry (HTTP $http_code)"
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Skip if image already exists
|
||||
if: steps.check_image.outputs.exists == 'true'
|
||||
run: echo "${{ matrix.description }} image ${{ steps.img.outputs.new_image }} already exists on registry; skipping build."
|
||||
|
||||
- name: Set up Buildx
|
||||
if: steps.img.outputs.changed == 'true'
|
||||
if: steps.check_image.outputs.exists == 'false'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
if: steps.img.outputs.changed == 'true'
|
||||
if: steps.check_image.outputs.exists == 'false'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.img.outputs.registry }}
|
||||
@@ -244,7 +272,7 @@ jobs:
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push ${{ matrix.description }} (exact tag from deployment)
|
||||
if: steps.img.outputs.changed == 'true'
|
||||
if: steps.check_image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.build_context }}
|
||||
|
||||
67
.gitea/workflows/check_code_in_sonarqube.yaml
Normal file
67
.gitea/workflows/check_code_in_sonarqube.yaml
Normal file
@@ -0,0 +1,67 @@
|
||||
on:
|
||||
push:
|
||||
# branches:
|
||||
# - main
|
||||
# - development
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
name: SonarQube Scan
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube Trigger
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checking out
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
coverage run --source='.' manage.py test
|
||||
coverage xml
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Cache SonarQube packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
- name: Download and setup SonarScanner
|
||||
run: |
|
||||
mkdir -p $HOME/.sonar
|
||||
wget -q https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-5.0.1.3006-linux.zip
|
||||
unzip -q sonar-scanner-cli-5.0.1.3006-linux.zip -d $HOME/.sonar/
|
||||
echo "$HOME/.sonar/sonar-scanner-5.0.1.3006-linux/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify Java version
|
||||
run: java -version
|
||||
|
||||
- name: SonarQube Scan
|
||||
env:
|
||||
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST }}
|
||||
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
|
||||
run: |
|
||||
sonar-scanner \
|
||||
-Dsonar.projectKey=${{ github.event.repository.name }} \
|
||||
-Dsonar.sources=. \
|
||||
-Dsonar.host.url=${SONAR_HOST_URL} \
|
||||
-Dsonar.token=${SONAR_TOKEN} \
|
||||
-Dsonar.python.coverage.reportPaths=coverage.xml
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,7 +12,9 @@ keys/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
package.json
|
||||
AGENT*.md
|
||||
# Diagram cache directory
|
||||
media/diagram_cache/
|
||||
.env
|
||||
data/db.sqlite3
|
||||
data/
|
||||
dataremote/
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim AS baustelle
|
||||
FROM python:3.15-rc-trixie AS baustelle
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
@@ -7,22 +7,23 @@ RUN pip install --upgrade pip
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.13-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.13/site-packages/ /usr/local/lib/python3.13/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
|
||||
RUN rm /usr/lib/x86_64-linux-gnu/libncur*
|
||||
RUN rm /usr/bin/tar /usr/lib/x86_64-linux-gnu/libncur*
|
||||
WORKDIR /app
|
||||
COPY --chown=appuser:appuser . .
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
USER appuser
|
||||
EXPOSE 8000
|
||||
RUN rm -rf /app/Dockerfile* \
|
||||
# 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 \
|
||||
/app/k8s \
|
||||
@@ -31,7 +32,8 @@ RUN rm -rf /app/Dockerfile* \
|
||||
/app/requirements.txt \
|
||||
/app/node_modules \
|
||||
/app/*.json \
|
||||
/app/test_*.py
|
||||
RUN python3 manage.py collectstatic
|
||||
/app/scripts \
|
||||
/app/test_*.py && \
|
||||
python3 /app/manage.py collectstatic --noinput
|
||||
CMD ["gunicorn","--bind","0.0.0.0:8000","--workers","3","VorgabenUI.wsgi:application"]
|
||||
|
||||
|
||||
241
Documentation/ArgoCD.md
Normal file
241
Documentation/ArgoCD.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# ArgoCD Configuration Documentation
|
||||
|
||||
## Overview
|
||||
This directory contains the ArgoCD application manifests for deploying the VorgabenUI application and its dependencies to Kubernetes.
|
||||
|
||||
## Files
|
||||
|
||||
### Application Manifests
|
||||
|
||||
#### `001_pvc.yaml`
|
||||
- **Purpose**: PersistentVolumeClaim for Django application data
|
||||
- **Storage**: 2Gi storage with ReadWriteMany access mode
|
||||
- **Storage Class**: Uses NFS storage class for shared storage across multiple pods
|
||||
- **Namespace**: vorgabenui
|
||||
|
||||
#### `deployment.yaml`
|
||||
- **Purpose**: Main application deployment configuration
|
||||
- **Contains**: Django application container, environment variables, resource limits
|
||||
- **Replicas**: Configurable replica count for high availability
|
||||
|
||||
#### `ingress.yaml`
|
||||
- **Purpose**: External access configuration
|
||||
- **Host**: Configurable hostname for the application
|
||||
- **TLS**: SSL/TLS termination configuration
|
||||
- **Backend**: Routes traffic to the Django application service
|
||||
|
||||
#### `nfs-pv.yaml`
|
||||
- **Purpose**: PersistentVolume definition for NFS storage
|
||||
- **Server**: 192.168.17.199
|
||||
- **Path**: /mnt/user/vorgabenui
|
||||
- **Access**: ReadWriteMany for multi-pod access
|
||||
- **Reclaim Policy**: Retain (data preserved after PVC deletion)
|
||||
|
||||
#### `nfs-storageclass.yaml`
|
||||
- **Purpose**: StorageClass definition for NFS volumes
|
||||
- **Provisioner**: kubernetes.io/no-provisioner (static provisioning)
|
||||
- **Volume Expansion**: Enabled for growing storage capacity
|
||||
- **Binding Mode**: Immediate (binds PV to PVC as soon as possible)
|
||||
|
||||
#### `diagrammer.yaml`
|
||||
- **Purpose**: Deployment configuration for the diagram generation service
|
||||
- **Function**: Handles diagram creation and caching for the application
|
||||
|
||||
## NFS Storage Configuration
|
||||
|
||||
### Prerequisites
|
||||
1. NFS server must be running at 192.168.17.199
|
||||
2. The directory `/mnt/user/vorgabenui` must exist and be exported
|
||||
3. Kubernetes nodes must have NFS client utilities installed
|
||||
4. For MicroK8s: `microk8s enable nfs`
|
||||
|
||||
## MicroK8s Addons Required
|
||||
|
||||
### Required Addons
|
||||
Enable the following MicroK8s addons before deployment:
|
||||
|
||||
```bash
|
||||
# Enable storage and NFS support
|
||||
sudo microk8s enable storage
|
||||
sudo microk8s enable nfs
|
||||
|
||||
# Enable ingress for external access
|
||||
sudo microk8s enable ingress
|
||||
|
||||
# Enable DNS for service discovery
|
||||
sudo microk8s enable dns
|
||||
|
||||
# Optional: Enable metrics for monitoring
|
||||
sudo microk8s enable metrics-server
|
||||
```
|
||||
|
||||
### Addon Descriptions
|
||||
|
||||
#### `storage`
|
||||
- **Purpose**: Provides default storage class for persistent volumes
|
||||
- **Required for**: Basic PVC functionality
|
||||
- **Note**: Works alongside our custom NFS storage class
|
||||
|
||||
#### `nfs`
|
||||
- **Purpose**: Installs NFS client utilities on all MicroK8s nodes
|
||||
- **Required for**: Mounting NFS volumes in pods
|
||||
- **Components**: Installs `nfs-common` package with mount helpers
|
||||
|
||||
#### `ingress`
|
||||
- **Purpose**: Provides Ingress controller for external HTTP/HTTPS access
|
||||
- **Required for**: `ingress.yaml` to function properly
|
||||
- **Implementation**: Uses NGINX Ingress Controller
|
||||
|
||||
#### `dns`
|
||||
- **Purpose**: Provides DNS service for service discovery within cluster
|
||||
- **Required for**: Inter-service communication
|
||||
- **Note**: Usually enabled by default in MicroK8s
|
||||
|
||||
#### `metrics-server` (Optional)
|
||||
- **Purpose**: Enables resource usage monitoring
|
||||
- **Required for**: `kubectl top` commands and HPA (Horizontal Pod Autoscaling)
|
||||
- **Recommended for**: Production monitoring
|
||||
|
||||
### Addon Verification
|
||||
After enabling addons, verify they are running:
|
||||
|
||||
```bash
|
||||
# Check addon status
|
||||
microk8s status
|
||||
|
||||
# Check pods in kube-system namespace
|
||||
microk8s kubectl get pods -n kube-system
|
||||
|
||||
# Check storage classes
|
||||
microk8s kubectl get storageclass
|
||||
|
||||
# Check ingress controller
|
||||
microk8s kubectl get pods -n ingress
|
||||
```
|
||||
|
||||
### Troubleshooting Addons
|
||||
|
||||
#### NFS Addon Issues
|
||||
```bash
|
||||
# Check if NFS utilities are installed
|
||||
which mount.nfs
|
||||
|
||||
# Manually install if addon fails
|
||||
sudo apt update && sudo apt install nfs-common
|
||||
|
||||
# Restart MicroK8s after manual installation
|
||||
sudo microk8s restart
|
||||
```
|
||||
|
||||
#### Ingress Issues
|
||||
```bash
|
||||
# Check ingress controller pods
|
||||
microk8s kubectl get pods -n ingress
|
||||
|
||||
# Check ingress services
|
||||
microk8s kubectl get svc -n ingress
|
||||
|
||||
# Test ingress connectivity
|
||||
curl -k https://your-domain.com
|
||||
```
|
||||
|
||||
#### Storage Issues
|
||||
```bash
|
||||
# List available storage classes
|
||||
microk8s kubectl get storageclass
|
||||
|
||||
# Check default storage class
|
||||
microk8s kubectl get storageclass -o yaml
|
||||
```
|
||||
|
||||
### Storage Architecture
|
||||
- **Storage Class**: `nfs` - Static provisioning for NFS shares
|
||||
- **Persistent Volume**: Pre-provisioned PV pointing to NFS server
|
||||
- **Persistent Volume Claim**: Claims the NFS storage for application use
|
||||
- **Access Mode**: ReadWriteMany allows multiple pods to access the same data
|
||||
|
||||
### NFS Server Setup
|
||||
On the NFS server (192.168.17.199), ensure the following:
|
||||
|
||||
```bash
|
||||
# Create the shared directory
|
||||
sudo mkdir -p /mnt/user/vorgabenui
|
||||
sudo chmod 755 /mnt/user/vorgabenui
|
||||
|
||||
# Add to /etc/exports
|
||||
echo "/mnt/user/vorgabenui *(rw,sync,no_subtree_check,no_root_squash)" | sudo tee -a /etc/exports
|
||||
|
||||
# Export the directory
|
||||
sudo exportfs -a
|
||||
sudo systemctl restart nfs-kernel-server
|
||||
```
|
||||
|
||||
## Deployment Order
|
||||
|
||||
1. **StorageClass** (`nfs-storageclass.yaml`) - Defines NFS storage class
|
||||
2. **PersistentVolume** (`nfs-pv.yaml`) - Creates the NFS volume
|
||||
3. **PersistentVolumeClaim** (`001_pvc.yaml`) - Claims storage for application
|
||||
4. **Application Deployments** (`deployment.yaml`, `diagrammer.yaml`) - Deploy application services
|
||||
5. **Ingress** (`ingress.yaml`) - Configure external access
|
||||
|
||||
## Configuration Notes
|
||||
|
||||
### Namespace
|
||||
All resources are deployed to the `vorgabenui` namespace.
|
||||
|
||||
### Storage Sizing
|
||||
- Current allocation: 2Gi
|
||||
- Volume expansion is enabled through the StorageClass
|
||||
- Monitor usage and adjust PVC size as needed
|
||||
|
||||
### Access Control
|
||||
- NFS export uses `no_root_squash` for container root access
|
||||
- Ensure proper network security between Kubernetes nodes and NFS server
|
||||
- Consider implementing network policies for additional security
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Mount Failures
|
||||
- **Error**: "bad option; for several filesystems you might need a /sbin/mount.<type> helper program"
|
||||
- **Solution**: Install NFS client utilities or enable NFS addon in MicroK8s
|
||||
|
||||
#### Permission Issues
|
||||
- **Error**: Permission denied when accessing mounted volume
|
||||
- **Solution**: Check NFS export permissions and ensure `no_root_squash` is set
|
||||
|
||||
#### Network Connectivity
|
||||
- **Error**: Connection timeout to NFS server
|
||||
- **Solution**: Verify network connectivity and firewall rules between nodes and NFS server
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Check PVC status
|
||||
kubectl get pvc -n vorgabenui
|
||||
|
||||
# Check PV status
|
||||
kubectl get pv
|
||||
|
||||
# Describe PVC for detailed information
|
||||
kubectl describe pvc django-data-pvc -n vorgabenui
|
||||
|
||||
# Check pod mount status
|
||||
kubectl describe pod <pod-name> -n vorgabenui
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Backup Strategy
|
||||
- The NFS server should have regular backups of `/mnt/user/vorgabenui`
|
||||
- Consider snapshot capabilities if using enterprise NFS solutions
|
||||
|
||||
### Monitoring
|
||||
- Monitor NFS server performance and connectivity
|
||||
- Track storage usage and plan capacity upgrades
|
||||
- Monitor pod restarts related to storage issues
|
||||
|
||||
### Updates
|
||||
- When updating storage configuration, update PV first, then PVC
|
||||
- Test changes in non-production environment first
|
||||
- Ensure backward compatibility when modifying NFS exports
|
||||
@@ -540,5 +540,5 @@ digraph {
|
||||
Bei Fragen oder Problemen mit Diagrammen:
|
||||
1. Code auf https://kroki.io/ testen
|
||||
2. Syntax-Dokumentation des jeweiligen Diagrammtyps konsultieren
|
||||
3. Diagramm-Cache leeren: `python manage.py clear_diagram_cache`
|
||||
3. (Nur mit Shell-Zugriff auf Kubernetes-Pod möglich): Diagramm-Cache leeren: `python manage.py clear_diagram_cache`
|
||||
4. Bei technischen Problemen: Information Security Management BIT kontaktieren
|
||||
|
||||
544
Documentation/modelle.md
Normal file
544
Documentation/modelle.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# Alle Modelle der vgui-cicd Django-Anwendung
|
||||
|
||||
Dieses Dokument beschreibt alle Datenmodelle in der vgui-cicd Anwendung mit ihren Eigenschaften, Beziehungen und Verwendungszwecken.
|
||||
|
||||
---
|
||||
|
||||
## App: dokumente
|
||||
|
||||
Die Hauptmodelle für die Verwaltung von Dokumenten, Vorgaben und deren Metadaten.
|
||||
|
||||
### Dokumententyp
|
||||
|
||||
**Zweck**: Kategorisierung von Dokumenten (z. B. Richtlinie, Standard).
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `name` (CharField, max_length=100, **PRIMARY KEY**)
|
||||
- `verantwortliche_ve` (CharField, max_length=255): Die verantwortliche Verwaltungseinheit
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt den Namen zurück
|
||||
- Dient als Klassifizierungskategorie für Dokumente
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Dokumententyp"`
|
||||
- `verbose_name_plural = "Dokumententypen"`
|
||||
|
||||
---
|
||||
|
||||
### Person
|
||||
|
||||
**Zweck**: Repräsentiert Personen, die als Autoren, Prüfer oder in anderen Rollen tätig sind.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `name` (CharField, max_length=100, **PRIMARY KEY**)
|
||||
- `funktion` (CharField, max_length=255): Funktionsbezeichnung der Person
|
||||
|
||||
**Beziehungen**:
|
||||
- Many-to-Many mit `Dokument` über `verfasste_dokumente` (Autoren)
|
||||
- Many-to-Many mit `Dokument` über `gepruefte_dokumente` (Prüfer)
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt den Namen zurück
|
||||
- `ordering = ['name']`: Alphabetische Sortierung
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name_plural = "Personen"`
|
||||
|
||||
---
|
||||
|
||||
### Thema
|
||||
|
||||
**Zweck**: Thematische Einordnung und Kategorisierung von Vorgaben innerhalb von Dokumenten.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `name` (CharField, max_length=100, **PRIMARY KEY**)
|
||||
- `erklaerung` (TextField, blank=True): Optionale Erklärung des Themas
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt den Namen zurück
|
||||
- Der erste Buchstabe des Themas wird in Vorgabennummern verwendet
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name_plural = "Themen"`
|
||||
|
||||
---
|
||||
|
||||
### Dokument
|
||||
|
||||
**Zweck**: Hauptmodell für ein einzelnes Dokument mit allen zugehörigen Metadaten und Inhalten.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `nummer` (CharField, max_length=50, **PRIMARY KEY**): Eindeutige Dokumentennummer
|
||||
- `dokumententyp` (ForeignKey → Dokumententyp, on_delete=PROTECT): Klassifizierung
|
||||
- `name` (CharField, max_length=255): Dokumenttitel
|
||||
- `autoren` (ManyToManyField → Person, related_name='verfasste_dokumente')
|
||||
- `pruefende` (ManyToManyField → Person, related_name='gepruefte_dokumente')
|
||||
- `gueltigkeit_von` (DateField, null=True, blank=True): Gültig ab Datum
|
||||
- `gueltigkeit_bis` (DateField, null=True, blank=True): Gültig bis Datum
|
||||
- `signatur_cso` (CharField, max_length=255, blank=True): CSO-Signatur
|
||||
- `anhaenge` (TextField, blank=True): Beschreibung von Anhängen
|
||||
- `aktiv` (BooleanField, blank=True): Aktivierungsstatus
|
||||
|
||||
**Beziehungen**:
|
||||
- 1-to-Many mit `Vorgabe` (über related_name='vorgaben')
|
||||
- 1-to-Many mit `Geltungsbereich`
|
||||
- 1-to-Many mit `Einleitung`
|
||||
- 1-to-Many mit `Changelog`
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` formatiert als "nummer – name"
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Dokument"`
|
||||
- `verbose_name_plural = "Dokumente"`
|
||||
|
||||
---
|
||||
|
||||
### Vorgabe
|
||||
|
||||
**Zweck**: Repräsentiert eine einzelne Vorgabe oder Anforderung innerhalb eines Dokuments.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `order` (IntegerField): Sortierreihenfolge für die Darstellung
|
||||
- `nummer` (IntegerField): Nummer innerhalb eines Themas/Dokuments. Muss nicht eindeutig sein (z.B. für geänderte Vorgaben)
|
||||
- `dokument` (ForeignKey → Dokument, on_delete=CASCADE, related_name='vorgaben')
|
||||
- `thema` (ForeignKey → Thema, on_delete=PROTECT): Thematische Einordnung
|
||||
- `titel` (CharField, max_length=255): Titel der Vorgabe
|
||||
- `referenzen` (ManyToManyField → Referenz, blank=True): Verweise auf externe Referenzen
|
||||
- `gueltigkeit_von` (DateField): Gültig ab Datum
|
||||
- `gueltigkeit_bis` (DateField, blank=True, null=True): Gültig bis Datum (offen = unbegrenzt)
|
||||
- `stichworte` (ManyToManyField → Stichwort, blank=True): Tags zur Kategorisierung
|
||||
- `relevanz` (ManyToManyField → Rolle, blank=True): Relevante Rollen
|
||||
|
||||
**Beziehungen**:
|
||||
- Foreign Key zu `Dokument` und `Thema`
|
||||
- Many-to-Many zu `Referenz`, `Stichwort`, `Rolle`
|
||||
- 1-to-Many zu `VorgabeLangtext`, `VorgabeKurztext`
|
||||
- 1-to-Many zu `Checklistenfrage`
|
||||
|
||||
**Wichtige Methoden**:
|
||||
|
||||
- `Vorgabennummer()` → str
|
||||
- Generiert eine eindeutige, lesbare Kennummer
|
||||
- Format: "{dokument.nummer}.{thema.name[0]}.{nummer}"
|
||||
- Beispiel: "R0066.A.1"
|
||||
|
||||
- `get_status(check_date=None, verbose=False)` → str
|
||||
- Bestimmt den Status einer Vorgabe zu einem gegebenen Datum
|
||||
- Parameter: `check_date` (Default: heute), `verbose` (Deutsche Beschreibung ja/nein)
|
||||
- Rückgabewerte:
|
||||
- "future": Vorgabe ist noch nicht gültig
|
||||
- "active": Vorgabe ist aktuell gültig
|
||||
- "expired": Vorgabe ist nicht mehr gültig
|
||||
- Verbose-Ausgaben enthalten Datumsangaben
|
||||
|
||||
- `sanity_check_vorgaben()` (statisch) → list
|
||||
- Findet zeitliche Konflikte zwischen Vorgaben mit gleicher Nummer/Thema/Dokument
|
||||
- Überprüft, ob sich Geltungszeiträume überschneiden
|
||||
- Gibt Liste mit Konflikt-Dictionaries zurück
|
||||
|
||||
- `clean()`
|
||||
- Validiert die Vorgabe vor dem Speichern
|
||||
- Ruft `find_conflicts()` auf
|
||||
- Wirft `ValidationError` bei erkannten Konflikten
|
||||
|
||||
- `find_conflicts()` → list
|
||||
- Findet Konflikte mit bestehenden Vorgaben (ausgenommen self)
|
||||
- Überprüft auf zeitliche Überschneidungen
|
||||
- Gibt Liste mit Konflikt-Details zurück
|
||||
|
||||
- `_date_ranges_intersect(start1, end1, start2, end2)` (statisch) → bool
|
||||
- Prüft, ob zwei Datumsbereiche sich überschneiden
|
||||
- `None` als Enddatum = unbegrenzter Bereich
|
||||
- Gibt `True` bei Überschneidung zurück
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt "Vorgabennummer: titel" zurück
|
||||
- Validierung von Gültigkeitszeiträumen ist implementiert
|
||||
- Sehr wichtiges Modell im Geschäftslogik-Kontext
|
||||
|
||||
**Meta**:
|
||||
- `ordering = ['order']`
|
||||
- `verbose_name_plural = "Vorgaben"`
|
||||
|
||||
---
|
||||
|
||||
### VorgabeLangtext
|
||||
|
||||
**Zweck**: Speichert ausführliche Textinhalte (Langtext) einer Vorgabe.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `abschnitt` (ForeignKey → Vorgabe, on_delete=CASCADE): Referenz zur Vorgabe
|
||||
- Erbt von `Textabschnitt` (siehe App: abschnitte):
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True)
|
||||
- `order` (PositiveIntegerField, default=0)
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Langtext-Abschnitt"`
|
||||
- `verbose_name_plural = "Langtext"`
|
||||
|
||||
---
|
||||
|
||||
### VorgabeKurztext
|
||||
|
||||
**Zweck**: Speichert kurze Textinhalte (Kurztext) einer Vorgabe.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `abschnitt` (ForeignKey → Vorgabe, on_delete=CASCADE): Referenz zur Vorgabe
|
||||
- Erbt von `Textabschnitt` (siehe App: abschnitte):
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True)
|
||||
- `order` (PositiveIntegerField, default=0)
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Kurztext-Abschnitt"`
|
||||
- `verbose_name_plural = "Kurztext"`
|
||||
|
||||
---
|
||||
|
||||
### Geltungsbereich
|
||||
|
||||
**Zweck**: Speichert den Geltungsbereich-Abschnitt eines Dokuments.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `geltungsbereich` (ForeignKey → Dokument, on_delete=CASCADE): Referenz zum Dokument
|
||||
- Erbt von `Textabschnitt` (siehe App: abschnitte):
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True)
|
||||
- `order` (PositiveIntegerField, default=0)
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Geltungsbereichs-Abschnitt"`
|
||||
- `verbose_name_plural = "Geltungsbereich"`
|
||||
|
||||
---
|
||||
|
||||
### Einleitung
|
||||
|
||||
**Zweck**: Speichert die Einleitungs-Abschnitte eines Dokuments.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `einleitung` (ForeignKey → Dokument, on_delete=CASCADE): Referenz zum Dokument
|
||||
- Erbt von `Textabschnitt` (siehe App: abschnitte):
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True)
|
||||
- `order` (PositiveIntegerField, default=0)
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Einleitungs-Abschnitt"`
|
||||
- `verbose_name_plural = "Einleitung"`
|
||||
|
||||
---
|
||||
|
||||
### Checklistenfrage
|
||||
|
||||
**Zweck**: Repräsentiert eine Frage für die Checkliste zu einer Vorgabe.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `vorgabe` (ForeignKey → Vorgabe, on_delete=CASCADE, related_name='checklistenfragen')
|
||||
- `frage` (CharField, max_length=255): Text der Checklistenfrage
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt den Fragetext zurück
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Frage für Checkliste"`
|
||||
- `verbose_name_plural = "Fragen für Checkliste"`
|
||||
|
||||
---
|
||||
|
||||
### VorgabenTable
|
||||
|
||||
**Zweck**: Proxy-Modell für `Vorgabe` für die Darstellung von Vorgaben in Tabellenform.
|
||||
|
||||
**Besonderheiten**:
|
||||
- Proxy-Modell (kein eigenes Datenbankschema)
|
||||
- Ermöglicht alternative Django-Admin-Ansicht
|
||||
- Erbt alle Felder und Methoden von `Vorgabe`
|
||||
|
||||
**Meta**:
|
||||
- `proxy = True`
|
||||
- `verbose_name = "Vorgabe (Tabellenansicht)"`
|
||||
- `verbose_name_plural = "Vorgaben (Tabellenansicht)"`
|
||||
|
||||
---
|
||||
|
||||
### Changelog
|
||||
|
||||
**Zweck**: Dokumentiert Änderungen und Versionshistorie für Dokumente.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `dokument` (ForeignKey → Dokument, on_delete=CASCADE, related_name='changelog'): Referenz zum Dokument
|
||||
- `autoren` (ManyToManyField → Person): Personen, die die Änderung vorgenommen haben
|
||||
- `datum` (DateField): Datum der Änderung
|
||||
- `aenderung` (TextField): Beschreibung der Änderung
|
||||
|
||||
**Beziehungen**:
|
||||
- Foreign Key zu `Dokument`
|
||||
- Many-to-Many zu `Person`
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` formatiert als "datum – dokumentnummer"
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Changelog-Eintrag"`
|
||||
- `verbose_name_plural = "Changelog"`
|
||||
|
||||
---
|
||||
|
||||
## App: abschnitte
|
||||
|
||||
Modelle für die Verwaltung von Textabschnitten, die von mehreren Modellen geerbt werden.
|
||||
|
||||
### AbschnittTyp
|
||||
|
||||
**Zweck**: Klassifizierung von Textabschnitten (z. B. "Beschreibung", "Erklärung", "Anleitung").
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `abschnitttyp` (CharField, max_length=100, **PRIMARY KEY**): Name des Abschnitttyps
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt den Namen zurück
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name_plural = "Abschnitttypen"`
|
||||
|
||||
---
|
||||
|
||||
### Textabschnitt (abstrakt)
|
||||
|
||||
**Zweck**: Abstrakte Basisklasse für Textinhalte, die mit anderen Modellen verknüpft sind.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, on_delete=PROTECT, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True): Der Textinhalt
|
||||
- `order` (PositiveIntegerField, default=0): Sortierreihenfolge
|
||||
|
||||
**Besonderheiten**:
|
||||
- Abstrakte Klasse (wird nicht direkt in der Datenbank gespeichert)
|
||||
- Wird von anderen Modellen geerbt: `VorgabeLangtext`, `VorgabeKurztext`, `Geltungsbereich`, `Einleitung`, `Referenzerklaerung`, `Stichworterklaerung`, `RollenBeschreibung`
|
||||
|
||||
**Meta**:
|
||||
- `abstract = True`
|
||||
- `verbose_name = "Abschnitt"`
|
||||
- `verbose_name_plural = "Abschnitte"`
|
||||
|
||||
---
|
||||
|
||||
## App: referenzen
|
||||
|
||||
Modelle für die Verwaltung von Referenzen und Verweisen auf externe Standards.
|
||||
|
||||
### Referenz (MPTT-Tree)
|
||||
|
||||
**Zweck**: Hierarchische Verwaltung von Referenzen und externen Normen (z. B. ISO-Standards, Gesetze, übergeordnete Vorgaben).
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `id` (AutoField, **PRIMARY KEY**)
|
||||
- `name_nummer` (CharField, max_length=100): Nummer/Kennung der Referenz (z. B. "ISO 27001")
|
||||
- `name_text` (CharField, max_length=255, blank=True): Ausführlicher Name/Beschreibung
|
||||
- `oberreferenz` (TreeForeignKey zu self, optional): Parent-Referenz für Hierarchien
|
||||
- `url` (URLField, blank=True): Link zur Referenz
|
||||
|
||||
**Beziehungen**:
|
||||
- Many-to-Many mit `Vorgabe`
|
||||
- MPTT Tree-Struktur für hierarchische Referenzen
|
||||
|
||||
**Wichtige Methoden**:
|
||||
|
||||
- `Path()` → str
|
||||
- Gibt die vollständige Pfad-Hierarchie als String zurück
|
||||
- Format: "Referenz → Subreferenz → Unterreferenz (Beschreibung)"
|
||||
- Beispiel: "ISO → 27000 → 27001 (Information Security Management)"
|
||||
|
||||
**Besonderheiten**:
|
||||
- Verwendet MPPT (Modified Preorder Tree Traversal) für Baumoperationen
|
||||
- `get_ancestors(include_self=True)`: Gibt alle Vorfahren zurück
|
||||
- `unterreferenzen`: Related_name für Kindreferenzen
|
||||
- Sortierung: Nach `name_nummer`
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name_plural = "Referenzen"`
|
||||
- **MPTTMeta**:
|
||||
- `parent_attr = 'oberreferenz'`
|
||||
- `order_insertion_by = ['name_nummer']`
|
||||
|
||||
---
|
||||
|
||||
### Referenzerklaerung
|
||||
|
||||
**Zweck**: Speichert Erklärungen und zusätzliche Informationen zu einer Referenz.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `erklaerung` (ForeignKey → Referenz, on_delete=CASCADE): Referenz zur Referenz
|
||||
- Erbt von `Textabschnitt`:
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True)
|
||||
- `order` (PositiveIntegerField, default=0)
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Erklärung"`
|
||||
- `verbose_name_plural = "Erklärungen"`
|
||||
|
||||
---
|
||||
|
||||
## App: stichworte
|
||||
|
||||
Modelle für die Verwaltung von Stichworte und Tags.
|
||||
|
||||
### Stichwort
|
||||
|
||||
**Zweck**: Einfache Tag/Keyword-Modell zur Kategorisierung von Vorgaben.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `stichwort` (CharField, max_length=50, **PRIMARY KEY**): Das Stichwort
|
||||
|
||||
**Beziehungen**:
|
||||
- Many-to-Many mit `Vorgabe`
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt das Stichwort zurück
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name_plural = "Stichworte"`
|
||||
|
||||
---
|
||||
|
||||
### Stichworterklaerung
|
||||
|
||||
**Zweck**: Speichert Erklärungen zu Stichworten.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `erklaerung` (ForeignKey → Stichwort, on_delete=CASCADE): Referenz zum Stichwort
|
||||
- Erbt von `Textabschnitt`:
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True)
|
||||
- `order` (PositiveIntegerField, default=0)
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Erklärung"`
|
||||
- `verbose_name_plural = "Erklärungen"`
|
||||
|
||||
---
|
||||
|
||||
## App: rollen
|
||||
|
||||
Modelle für die Verwaltung von Rollen und deren Beschreibungen.
|
||||
|
||||
### Rolle
|
||||
|
||||
**Zweck**: Definiert Rollen/Positionen im Unternehmen (z. B. "Geschäftsleiter", "IT-Sicherheit", "Datenschutzbeauftragter").
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `name` (CharField, max_length=100, **PRIMARY KEY**): Name der Rolle
|
||||
|
||||
**Beziehungen**:
|
||||
- Many-to-Many mit `Vorgabe` (über `relevanz`)
|
||||
|
||||
**Besonderheiten**:
|
||||
- `__str__()` gibt den Namen zurück
|
||||
- Wird verwendet, um Rollen zu markieren, die von einer Vorgabe betroffen sind
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name_plural = "Rollen"`
|
||||
|
||||
---
|
||||
|
||||
### RollenBeschreibung
|
||||
|
||||
**Zweck**: Speichert detaillierte Beschreibungen und Informationen zu einer Rolle.
|
||||
|
||||
**Wichtige Felder**:
|
||||
- `abschnitt` (ForeignKey → Rolle, on_delete=CASCADE): Referenz zur Rolle
|
||||
- Erbt von `Textabschnitt`:
|
||||
- `abschnitttyp` (ForeignKey → AbschnittTyp, optional)
|
||||
- `inhalt` (TextField, blank=True, null=True)
|
||||
- `order` (PositiveIntegerField, default=0)
|
||||
|
||||
**Meta**:
|
||||
- `verbose_name = "Rollenbeschreibungs-Abschnitt"`
|
||||
- `verbose_name_plural = "Rollenbeschreibung"`
|
||||
|
||||
---
|
||||
|
||||
## Allgemeine Hinweise zur Modellverwaltung
|
||||
|
||||
### Primärschlüssel-Strategie
|
||||
- Viele Modelle verwenden CharField-basierte Primärschlüssel (`name`, `nummer`, `stichwort`)
|
||||
- Dies ermöglicht direkte Verwendung von Strings als Identifikatoren
|
||||
- Vorteil: Lesbarkeit; Nachteil: Umbenennungen sind kritisch
|
||||
|
||||
### On-Delete-Strategien
|
||||
- **PROTECT**: Verwendet für wichtige Beziehungen (z. B. Dokumententyp, Thema, AbschnittTyp)
|
||||
- Verhindert versehentliches Löschen von Daten, auf die verwiesen wird
|
||||
- **CASCADE**: Verwendet für Unterkomponenten (z. B. Vorgabe → Dokument)
|
||||
- Löscht abhängige Datensätze automatisch
|
||||
- **SET_NULL**: Nur bei optionalen Referenzen (z. B. Oberreferenz in Referenz-Tree)
|
||||
|
||||
### Validierungsmechanismen
|
||||
- **Vorgabe.clean()**: Validiert Gültigkeitszeiträume
|
||||
- **Vorgabe.find_conflicts()**: Prüft zeitliche Überschneidungen
|
||||
- Wird von Django-Admin automatisch aufgerufen vor dem Speichern
|
||||
|
||||
### MPTT (Modified Preorder Tree Traversal)
|
||||
- Verwendet in `Referenz` für hierarchische Strukturen
|
||||
- Ermöglicht effiziente Abfragen von Vorfahren und Nachkommen
|
||||
- Zusätzliche Datenbank-Felder für Tree-Management (automatisch verwaltet)
|
||||
|
||||
### Textabschnitt-Vererbung
|
||||
- Mehrere Modelle erben von `Textabschnitt`
|
||||
- Wird verwendet für Lang-/Kurztexte, Erklärungen, Beschreibungen
|
||||
- `order`-Feld ermöglicht Sortierung mehrerer Abschnitte
|
||||
|
||||
### Datumsverwaltung
|
||||
- `gueltigkeit_von`: Immer erforderlich für Vorgaben
|
||||
- `gueltigkeit_bis`: Optional; `None` bedeutet unbegrenzte Gültigkeit
|
||||
- `_date_ranges_intersect()` prüft korrekt auf Überschneidungen mit None-Werten
|
||||
|
||||
### Many-to-Many-Beziehungen
|
||||
- Vielfach verwendet für flexible Zuordnungen (Autoren, Stichworte, Rollen, Referenzen)
|
||||
- `related_name`-Attribute ermöglichen rückwärts Zugriff
|
||||
- Beispiel: `dokument.vorgaben.all()`, `person.verfasste_dokumente.all()`
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung der Beziehungen
|
||||
|
||||
```
|
||||
Dokumententyp ← Dokument
|
||||
Person ← Dokument (Autoren/Prüfer)
|
||||
Dokument → Vorgabe (1-to-Many)
|
||||
Dokument → Geltungsbereich (1-to-Many)
|
||||
Dokument → Einleitung (1-to-Many)
|
||||
Dokument → Changelog (1-to-Many)
|
||||
|
||||
Thema ← Vorgabe
|
||||
Vorgabe → VorgabeLangtext (1-to-Many)
|
||||
Vorgabe → VorgabeKurztext (1-to-Many)
|
||||
Vorgabe → Checklistenfrage (1-to-Many)
|
||||
Vorgabe ← Referenz (Many-to-Many)
|
||||
Vorgabe ← Stichwort (Many-to-Many)
|
||||
Vorgabe ← Rolle (Many-to-Many)
|
||||
|
||||
Referenz → Referenz (Hierarchie via MPPT)
|
||||
Referenz → Referenzerklaerung (1-to-Many)
|
||||
|
||||
Stichwort → Stichworterklaerung (1-to-Many)
|
||||
|
||||
Rolle → RollenBeschreibung (1-to-Many)
|
||||
|
||||
AbschnittTyp ← Textabschnitt (von verschiedenen Modellen geerbt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entwicklungsrichtlinien
|
||||
|
||||
- Alle Modelle sollten aussagekräftige `__str__()`-Methoden haben
|
||||
- `verbose_name` und `verbose_name_plural` sollten auf Deutsch sein (für Django-Admin)
|
||||
- Validierungslogik (z. B. `clean()`) sollte implementiert werden für komplexe Business-Logic
|
||||
- Related-Names sollten aussagekräftig und konsistent sein
|
||||
- Textinhalte sollten die `Textabschnitt`-Basisklasse erben
|
||||
- Datumsverwaltung: Immer auf None-Werte bei `gueltigkeit_bis` achten, wenn Vorgaben noch aktiv sind.
|
||||
92
Documentation/modelle_dokumente.md
Normal file
92
Documentation/modelle_dokumente.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Modelle (App: dokumente)
|
||||
|
||||
Kurzbeschreibungen der Modelle in dokumente/models.py.
|
||||
|
||||
## Dokumententyp
|
||||
- Zweck: Kategorisierung von Dokumenten (z. B. Richtlinie, Verfahren).
|
||||
- Wichtige Felder: `name` (CharField, PK), `verantwortliche_ve` (CharField).
|
||||
- Besonderheiten: `__str__()` gibt `name` zurück.
|
||||
- Meta: `verbose_name` und `verbose_name_plural` gesetzt.
|
||||
|
||||
## Person
|
||||
- Zweck: Repräsentiert Personen (Autoren, Prüfer).
|
||||
- Wichtige Felder: `name` (CharField, PK), `funktion` (CharField).
|
||||
- Beziehungen: Many-to-many mit Dokument über `autoren` und `pruefende`.
|
||||
- Besonderheiten: `__str__()` gibt `name` zurück; `ordering = ['name']`.
|
||||
- Meta: `verbose_name_plural = "Personen"`.
|
||||
|
||||
## Thema
|
||||
- Zweck: Thematische Einordnung von Vorgaben.
|
||||
- Wichtige Felder: `name` (CharField, PK), `erklaerung` (TextField, optional).
|
||||
- Besonderheiten: `__str__()` gibt `name` zurück.
|
||||
|
||||
## Dokument
|
||||
- Zweck: Hauptobjekt; ein einzelnes Dokument mit Metadaten.
|
||||
- Wichtige Felder:
|
||||
- `nummer` (CharField, PK)
|
||||
- `dokumententyp` (FK → Dokumententyp, on_delete=PROTECT)
|
||||
- `name` (CharField)
|
||||
- `autoren`, `pruefende` (ManyToManyField → Person)
|
||||
- `gueltigkeit_von`, `gueltigkeit_bis` (DateField, optional)
|
||||
- `aktiv` (BooleanField)
|
||||
- `signatur_cso`, `anhaenge` (Metadaten)
|
||||
- Besonderheiten: `__str__()` formatiert als "nummer – name".
|
||||
- Meta: `verbose_name` / `verbose_name_plural`.
|
||||
|
||||
## Vorgabe
|
||||
- Zweck: Einzelne Vorgabe / Anforderung innerhalb eines Dokuments.
|
||||
- Wichtige Felder:
|
||||
- `order` (IntegerField) — Sortierreihenfolge
|
||||
- `nummer` (IntegerField) — Nummer innerhalb Thema/Dokument
|
||||
- `dokument` (FK → Dokument, CASCADE, related_name='vorgaben')
|
||||
- `thema` (FK → Thema, PROTECT)
|
||||
- `titel` (CharField)
|
||||
- `referenzen` (M2M → Referenz, optional)
|
||||
- `stichworte` (M2M → Stichwort, optional)
|
||||
- `relevanz` (M2M → Rolle, optional)
|
||||
- `gueltigkeit_von`, `gueltigkeit_bis` (Datum/Felder)
|
||||
- Beziehungen: zu Dokument, Thema, Referenzen, Stichworte, Rollen.
|
||||
- Wichtige Methoden:
|
||||
- `Vorgabennummer()` — generiert eine lesbare Kennung (z. B. "DOK. T. N").
|
||||
- `get_status(check_date, verbose)` — liefert "future", "active" oder "expired" oder eine deutsche Statusbeschreibung, abhängig von Gültigkeitsdaten.
|
||||
- `sanity_check_vorgaben()` (static) — findet Konflikte zwischen Vorgaben mit gleicher Nummer/Thema/Dokument, deren Zeiträume sich überschneiden.
|
||||
- `clean()` — ruft `find_conflicts()` auf und wirft ValidationError bei Konflikten.
|
||||
- `find_conflicts()` — prüft Konflikte mit bestehenden Vorgaben (ohne sich selbst).
|
||||
- `_date_ranges_intersect(...)` (static) — prüft, ob sich zwei Datumsbereiche überschneiden (None = offen).
|
||||
- Besonderheiten: `__str__()` gibt "Vorgabennummer: titel" zurück.
|
||||
- Meta: `ordering = ['order']`, `verbose_name_plural = "Vorgaben"`.
|
||||
|
||||
## VorgabeLangtext, VorgabeKurztext
|
||||
- Zweck: Textabschnitts-Modelle, erben von `Textabschnitt` (aus abschnitte.models).
|
||||
- Wichtige Felder: je ein FK `abschnitt` → Vorgabe.
|
||||
- Besonderheit: konkrete Untertypen für Lang- und Kurztexte; Meta-`verbose_name` gesetzt.
|
||||
|
||||
## Geltungsbereich, Einleitung
|
||||
- Zweck: Dokumentbezogene Textabschnitte (erben von `Textabschnitt`).
|
||||
- Wichtige Felder: FK zum `Dokument` (`geltungsbereich` bzw. `einleitung`).
|
||||
- Meta: `verbose_name`/`verbose_name_plural` gesetzt.
|
||||
|
||||
## Checklistenfrage
|
||||
- Zweck: Einzelne Frage für Checklisten zu einer Vorgabe.
|
||||
- Wichtige Felder: `vorgabe` (FK → Vorgabe, related_name="checklistenfragen"), `frage` (CharField).
|
||||
- Besonderheiten: `__str__()` gibt `frage` zurück.
|
||||
|
||||
## VorgabenTable
|
||||
- Zweck: Proxy-Modell für Vorgabe zur Darstellung (Tabellenansicht).
|
||||
- Besonderheiten: kein eigenes Schema; nur Meta-Attribute (`proxy = True`, `verbose_name`).
|
||||
|
||||
## Changelog
|
||||
- Zweck: Änderungsverzeichnis-Eintrag für ein Dokument.
|
||||
- Wichtige Felder:
|
||||
- `dokument` (FK → Dokument, related_name='changelog')
|
||||
- `autoren` (M2M → Person)
|
||||
- `datum` (DateField)
|
||||
- `aenderung` (TextField)
|
||||
- Besonderheiten: `__str__()` formatiert als "datum – dokumentnummer".
|
||||
- Meta: `verbose_name` / `verbose_name_plural`.
|
||||
|
||||
Hinweise zur Pflege
|
||||
- Wichtige Relationen nutzen häufig on_delete=PROTECT, um versehentliche Löschungen zu vermeiden.
|
||||
- Viele Modelle haben CharField-Primärschlüssel (z. B. `nummer`, `name`).
|
||||
- Validierungslogik für zeitliche Konflikte ist in Vorgabe implementiert (clean / find_conflicts).
|
||||
- Textabschnitt-Modelle erben Verhalten aus `abschnitte.models.Textabschnitt` — dort sind Anzeige- und Inhaltsregeln definiert.
|
||||
@@ -15,7 +15,7 @@ Dieses Dokument bietet einen umfassenden Überblick über alle Tests im vgui-cic
|
||||
|
||||
## abschnitte App Tests
|
||||
|
||||
Die abschnitte App enthält 32 Tests, die Modelle, Utility-Funktionen, Diagram-Caching und Management-Befehle abdecken.
|
||||
Die abschnitte App enthält 33 Tests, die Modelle, Utility-Funktionen, Diagram-Caching, Management-Befehle und Sicherheit abdecken.
|
||||
|
||||
### Modell-Tests
|
||||
|
||||
@@ -58,6 +58,7 @@ Die abschnitte App enthält 32 Tests, die Modelle, Utility-Funktionen, Diagram-C
|
||||
- **test_render_text_with_footnotes**: Verarbeitet Text, der Fußnoten enthält
|
||||
- **test_render_abschnitt_without_type**: Behandelt Textabschnitte ohne AbschnittTyp
|
||||
- **test_render_abschnitt_with_empty_content**: Behandelt Textabschnitte mit leerem Inhalt
|
||||
- **test_render_textabschnitte_xss_prevention**: Überprüft, dass bösartiger HTML-Code und Skript-Tags aus gerenderten Inhalten bereinigt werden, um XSS-Angriffe zu verhindern
|
||||
|
||||
### Diagram-Caching-Tests
|
||||
|
||||
@@ -86,7 +87,7 @@ Die abschnitte App enthält 32 Tests, die Modelle, Utility-Funktionen, Diagram-C
|
||||
|
||||
## dokumente App Tests
|
||||
|
||||
Die dokumente App enthält 98 Tests und ist damit die umfassendste Test-Suite, die alle Modelle, Views, URLs und Geschäftslogik abdeckt.
|
||||
Die dokumente App enthält 121 Tests und ist damit die umfassendste Test-Suite, die alle Modelle, Views, URLs, Geschäftslogik und Kommentarfunktionalität mit XSS-Schutz abdeckt.
|
||||
|
||||
### Modell-Tests
|
||||
|
||||
@@ -130,6 +131,14 @@ Die dokumente App enthält 98 Tests und ist damit die umfassendste Test-Suite, d
|
||||
- **test_checklistenfrage_str**: Überprüft, dass die String-Repräsentation lange Fragen kürzt
|
||||
- **test_checklistenfrage_related_name**: Testet die umgekehrte Beziehung von Vorgabe
|
||||
|
||||
#### VorgabeCommentModelTest
|
||||
- **test_comment_creation**: Testet die Erstellung von VorgabeComment mit Vorgabe, Benutzer und Text
|
||||
- **test_comment_str**: Überprüft, dass die String-Repräsentation Benutzername und Vorgabennummer enthält
|
||||
- **test_comment_related_name**: Testet die umgekehrte Beziehung von Vorgabe
|
||||
- **test_comment_ordering**: Testet, dass Kommentare nach created_at absteigend sortiert sind (neueste zuerst)
|
||||
- **test_comment_timestamps_auto_update**: Testet, dass sich updated_at ändert, wenn ein Kommentar geändert wird
|
||||
- **test_multiple_users_can_comment**: Testet, dass mehrere Benutzer zur selben Vorgabe kommentieren können
|
||||
|
||||
### Text-Abschnitt-Tests
|
||||
|
||||
#### DokumentTextAbschnitteTest
|
||||
@@ -216,6 +225,40 @@ Die dokumente App enthält 98 Tests und ist damit die umfassendste Test-Suite, d
|
||||
- **test_vorgabe_links**: Testet, dass Vorgaben zu korrekten Admin-Seiten verlinken
|
||||
- **test_back_link**: Testet, dass der Zurück-Link zur Standardübersicht existiert
|
||||
|
||||
### Kommentar-Funktionalität Tests
|
||||
|
||||
#### GetVorgabeCommentsViewTest
|
||||
- **test_get_comments_requires_login**: Testet, dass anonyme Benutzer keine Kommentare sehen können und weitergeleitet werden
|
||||
- **test_regular_user_sees_only_own_comments**: Testet, dass normale Benutzer nur ihre eigenen Kommentare sehen
|
||||
- **test_staff_user_sees_all_comments**: Testet, dass Staff-Benutzer alle Kommentare sehen können
|
||||
- **test_get_comments_returns_404_for_nonexistent_vorgabe**: Testet 404-Antwort für nicht existierende Vorgabe
|
||||
- **test_comments_are_html_escaped**: Testet HTML-Escaping zur Verhinderung von XSS-Angriffen (z.B. `<script>`-Tags)
|
||||
- **test_line_breaks_preserved**: Testet, dass Zeilenumbrüche in `<br>`-Tags umgewandelt werden
|
||||
- **test_security_headers_present**: Testet, dass Content-Security-Policy und X-Content-Type-Options Header gesetzt sind
|
||||
|
||||
#### AddVorgabeCommentViewTest
|
||||
- **test_add_comment_requires_login**: Testet, dass anonyme Benutzer keine Kommentare hinzufügen können
|
||||
- **test_add_comment_requires_post**: Testet, dass nur POST-Methode erlaubt ist (405 für GET)
|
||||
- **test_add_comment_success**: Testet erfolgreiche Kommentarerstellung mit gültigen Daten
|
||||
- **test_add_empty_comment_fails**: Testet, dass leere Kommentare mit 400-Fehler abgelehnt werden
|
||||
- **test_add_whitespace_only_comment_fails**: Testet, dass Kommentare nur mit Leerzeichen abgelehnt werden
|
||||
- **test_add_too_long_comment_fails**: Testet, dass Kommentare über 2000 Zeichen abgelehnt werden
|
||||
- **test_add_comment_xss_script_tag_blocked**: Testet, dass Kommentare mit `<script>`-Tags blockiert werden
|
||||
- **test_add_comment_xss_javascript_protocol_blocked**: Testet, dass `javascript:`-Protokoll blockiert wird
|
||||
- **test_add_comment_xss_event_handlers_blocked**: Testet, dass Event-Handler (onload, onerror, onclick, onmouseover) blockiert werden
|
||||
- **test_add_comment_invalid_json_fails**: Testet, dass ungültige JSON-Payloads abgelehnt werden
|
||||
- **test_add_comment_nonexistent_vorgabe_fails**: Testet 404-Antwort für nicht existierende Vorgabe
|
||||
- **test_add_comment_security_headers**: Testet, dass Sicherheits-Header in Antworten vorhanden sind
|
||||
|
||||
#### DeleteVorgabeCommentViewTest
|
||||
- **test_delete_comment_requires_login**: Testet, dass anonyme Benutzer keine Kommentare löschen können
|
||||
- **test_delete_comment_requires_post**: Testet, dass nur POST-Methode erlaubt ist (405 für GET)
|
||||
- **test_user_can_delete_own_comment**: Testet, dass Benutzer ihre eigenen Kommentare löschen können
|
||||
- **test_user_cannot_delete_other_users_comment**: Testet, dass Benutzer keine Kommentare anderer löschen können (403 Forbidden)
|
||||
- **test_staff_can_delete_any_comment**: Testet, dass Staff-Benutzer jeden Kommentar löschen können
|
||||
- **test_delete_nonexistent_comment_returns_404**: Testet 404-Antwort für nicht existierenden Kommentar
|
||||
- **test_delete_comment_security_headers**: Testet, dass Sicherheits-Header in Antworten vorhanden sind
|
||||
|
||||
---
|
||||
|
||||
## pages App Tests
|
||||
@@ -332,9 +375,17 @@ Die stichworte App enthält 18 Tests, die Schlüsselwortmodelle und ihre Sortier
|
||||
|
||||
## Test-Statistiken
|
||||
|
||||
- **Gesamt-Tests**: 206
|
||||
- **abschnitte**: 32 Tests
|
||||
- **dokumente**: 116 Tests (98 in tests.py + 9 in test_json.py + 9 JSON-Tests in Haupt-tests.py)
|
||||
- **Gesamt-Tests**: 230
|
||||
- **abschnitte**: 33 Tests (einschließlich XSS-Prävention)
|
||||
- **dokumente**: 121 Tests (einschließlich Kommentarfunktionalität mit XSS-Schutz)
|
||||
- Modell-Tests: 44 Tests
|
||||
- View-Tests: 7 Tests
|
||||
- URL-Pattern-Tests: 4 Tests
|
||||
- Sanity-Check-Tests: 16 Tests
|
||||
- Management-Befehl-Tests: 2 Tests
|
||||
- JSON-Export-Tests: 9 Tests
|
||||
- Unvollständige-Vorgaben-Tests: 15 Tests
|
||||
- Kommentar-Tests: 24 Tests (6 Modell + 18 View-Tests)
|
||||
- **pages**: 4 Tests
|
||||
- **referenzen**: 18 Tests
|
||||
- **rollen**: 18 Tests
|
||||
@@ -348,6 +399,17 @@ Die stichworte App enthält 18 Tests, die Schlüsselwortmodelle und ihre Sortier
|
||||
4. **Utility-Funktionen**: Textverarbeitung, Caching, Formatierung
|
||||
5. **Management-Befehle**: CLI-Schnittstelle und Ausgabeverarbeitung
|
||||
6. **Integration**: App-übergreifende Funktionalität und Datenfluss
|
||||
7. **Sicherheit**:
|
||||
- XSS-Prävention durch HTML-Bereinigung beim Rendern von Inhalten
|
||||
- XSS-Angriffsverhinderung im Kommentarsystem (Script-Tags, javascript:-Protokoll, Event-Handler)
|
||||
- Eingabevalidierung und -bereinigung
|
||||
- Autorisierungsprüfungen (Staff vs. normale Benutzer)
|
||||
- Sicherheits-Header (Content-Security-Policy, X-Content-Type-Options)
|
||||
8. **Kommentar-Funktionalität**:
|
||||
- CRUD-Operationen (Create, Read, Delete)
|
||||
- Benutzerberechtigungen und -besitz
|
||||
- HTML-Escaping und Erhalt von Zeilenumbrüchen
|
||||
- Verhinderung mehrerer XSS-Angriffsvektoren
|
||||
|
||||
## Ausführen der Tests
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ This document provides a comprehensive overview of all tests in the vgui-cicd Dj
|
||||
|
||||
## abschnitte App Tests
|
||||
|
||||
The abschnitte app contains 32 tests covering models, utility functions, diagram caching, and management commands.
|
||||
The abschnitte app contains 33 tests covering models, utility functions, diagram caching, management commands, and security.
|
||||
|
||||
### Model Tests
|
||||
|
||||
@@ -58,6 +58,7 @@ The abschnitte app contains 32 tests covering models, utility functions, diagram
|
||||
- **test_render_text_with_footnotes**: Processes text containing footnotes
|
||||
- **test_render_abschnitt_without_type**: Handles Textabschnitte without AbschnittTyp
|
||||
- **test_render_abschnitt_with_empty_content**: Handles Textabschnitte with empty content
|
||||
- **test_render_textabschnitte_xss_prevention**: Verifies that malicious HTML and script tags are sanitized from rendered content to prevent XSS attacks
|
||||
|
||||
### Diagram Caching Tests
|
||||
|
||||
@@ -86,7 +87,7 @@ The abschnitte app contains 32 tests covering models, utility functions, diagram
|
||||
|
||||
## dokumente App Tests
|
||||
|
||||
The dokumente app contains 98 tests, making it the most comprehensive test suite, covering all models, views, URLs, and business logic.
|
||||
The dokumente app contains 121 tests, making it the most comprehensive test suite, covering all models, views, URLs, business logic, and comment functionality with XSS protection.
|
||||
|
||||
### Model Tests
|
||||
|
||||
@@ -130,6 +131,14 @@ The dokumente app contains 98 tests, making it the most comprehensive test suite
|
||||
- **test_checklistenfrage_str**: Verifies string representation truncates long questions
|
||||
- **test_checklistenfrage_related_name**: Tests the reverse relationship from Vorgabe
|
||||
|
||||
#### VorgabeCommentModelTest
|
||||
- **test_comment_creation**: Tests VorgabeComment creation with vorgabe, user, and text
|
||||
- **test_comment_str**: Verifies string representation includes username and Vorgabennummer
|
||||
- **test_comment_related_name**: Tests the reverse relationship from Vorgabe
|
||||
- **test_comment_ordering**: Tests comments are ordered by created_at descending (newest first)
|
||||
- **test_comment_timestamps_auto_update**: Tests that updated_at changes when comment is modified
|
||||
- **test_multiple_users_can_comment**: Tests multiple users can comment on same Vorgabe
|
||||
|
||||
### Text Abschnitt Tests
|
||||
|
||||
#### DokumentTextAbschnitteTest
|
||||
@@ -216,6 +225,40 @@ The dokumente app contains 98 tests, making it the most comprehensive test suite
|
||||
- **test_vorgabe_links**: Tests Vorgaben link to correct admin pages
|
||||
- **test_back_link**: Tests back link to standard list exists
|
||||
|
||||
### Comment Functionality Tests
|
||||
|
||||
#### GetVorgabeCommentsViewTest
|
||||
- **test_get_comments_requires_login**: Tests anonymous users cannot view comments and are redirected
|
||||
- **test_regular_user_sees_only_own_comments**: Tests regular users only see their own comments
|
||||
- **test_staff_user_sees_all_comments**: Tests staff users can see all comments
|
||||
- **test_get_comments_returns_404_for_nonexistent_vorgabe**: Tests 404 response for non-existent Vorgabe
|
||||
- **test_comments_are_html_escaped**: Tests HTML escaping prevents XSS attacks (e.g., `<script>` tags)
|
||||
- **test_line_breaks_preserved**: Tests line breaks are converted to `<br>` tags
|
||||
- **test_security_headers_present**: Tests Content-Security-Policy and X-Content-Type-Options headers are set
|
||||
|
||||
#### AddVorgabeCommentViewTest
|
||||
- **test_add_comment_requires_login**: Tests anonymous users cannot add comments
|
||||
- **test_add_comment_requires_post**: Tests only POST method is allowed (405 for GET)
|
||||
- **test_add_comment_success**: Tests successful comment creation with valid data
|
||||
- **test_add_empty_comment_fails**: Tests empty comments are rejected with 400 error
|
||||
- **test_add_whitespace_only_comment_fails**: Tests whitespace-only comments are rejected
|
||||
- **test_add_too_long_comment_fails**: Tests comments exceeding 2000 characters are rejected
|
||||
- **test_add_comment_xss_script_tag_blocked**: Tests comments with `<script>` tags are blocked
|
||||
- **test_add_comment_xss_javascript_protocol_blocked**: Tests `javascript:` protocol is blocked
|
||||
- **test_add_comment_xss_event_handlers_blocked**: Tests event handlers (onload, onerror, onclick, onmouseover) are blocked
|
||||
- **test_add_comment_invalid_json_fails**: Tests invalid JSON payloads are rejected
|
||||
- **test_add_comment_nonexistent_vorgabe_fails**: Tests 404 response for non-existent Vorgabe
|
||||
- **test_add_comment_security_headers**: Tests security headers are present in responses
|
||||
|
||||
#### DeleteVorgabeCommentViewTest
|
||||
- **test_delete_comment_requires_login**: Tests anonymous users cannot delete comments
|
||||
- **test_delete_comment_requires_post**: Tests only POST method is allowed (405 for GET)
|
||||
- **test_user_can_delete_own_comment**: Tests users can delete their own comments
|
||||
- **test_user_cannot_delete_other_users_comment**: Tests users cannot delete others' comments (403 Forbidden)
|
||||
- **test_staff_can_delete_any_comment**: Tests staff users can delete any comment
|
||||
- **test_delete_nonexistent_comment_returns_404**: Tests 404 response for non-existent comment
|
||||
- **test_delete_comment_security_headers**: Tests security headers are present in responses
|
||||
|
||||
---
|
||||
|
||||
## pages App Tests
|
||||
@@ -332,9 +375,17 @@ The stichworte app contains 18 tests covering keyword models and their ordering.
|
||||
|
||||
## Test Statistics
|
||||
|
||||
- **Total Tests**: 206
|
||||
- **abschnitte**: 32 tests
|
||||
- **dokumente**: 116 tests (98 in tests.py + 9 in test_json.py + 9 JSON tests in main tests.py)
|
||||
- **Total Tests**: 230
|
||||
- **abschnitte**: 33 tests (including XSS prevention)
|
||||
- **dokumente**: 121 tests (including comment functionality with XSS protection)
|
||||
- Model tests: 44 tests
|
||||
- View tests: 7 tests
|
||||
- URL pattern tests: 4 tests
|
||||
- Sanity check tests: 16 tests
|
||||
- Management command tests: 2 tests
|
||||
- JSON export tests: 9 tests
|
||||
- Incomplete Vorgaben tests: 15 tests
|
||||
- Comment tests: 24 tests (6 model + 18 view tests)
|
||||
- **pages**: 4 tests
|
||||
- **referenzen**: 18 tests
|
||||
- **rollen**: 18 tests
|
||||
@@ -348,6 +399,17 @@ The stichworte app contains 18 tests covering keyword models and their ordering.
|
||||
4. **Utility Functions**: Text processing, caching, formatting
|
||||
5. **Management Commands**: CLI interface and output handling
|
||||
6. **Integration**: Cross-app functionality and data flow
|
||||
7. **Security**:
|
||||
- XSS prevention through HTML sanitization in content rendering
|
||||
- XSS attack prevention in comment system (script tags, javascript: protocol, event handlers)
|
||||
- Input validation and sanitization
|
||||
- Authorization checks (staff vs. regular users)
|
||||
- Security headers (Content-Security-Policy, X-Content-Type-Options)
|
||||
8. **Comment Functionality**:
|
||||
- CRUD operations (Create, Read, Delete)
|
||||
- User permissions and ownership
|
||||
- HTML escaping and line break preservation
|
||||
- Multiple XSS attack vector prevention
|
||||
|
||||
## Running the Tests
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -20,19 +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")
|
||||
|
||||
TEMPLATES = [
|
||||
{"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"APP_DIRS": True,
|
||||
}
|
||||
]
|
||||
|
||||
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', "10.128.128.144,localhost,127.0.0.1,*").split(",")
|
||||
|
||||
# Application definition
|
||||
|
||||
@@ -43,6 +58,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'whitenoise',
|
||||
'dokumente',
|
||||
'abschnitte',
|
||||
'stichworte',
|
||||
@@ -54,6 +70,7 @@ INSTALLED_APPS = [
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
@@ -121,7 +138,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
|
||||
LANGUAGE_CODE = 'de-ch'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = 'Europe/Zurich'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -133,7 +150,7 @@ USE_TZ = True
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
#STATIC_ROOT="/home/adebaumann/VorgabenUI/staticfiles/"
|
||||
STATIC_ROOT="/app/staticfiles/"
|
||||
STATIC_ROOT="staticfiles/"
|
||||
STATICFILES_DIRS= (
|
||||
os.path.join(BASE_DIR,"static"),
|
||||
)
|
||||
@@ -149,9 +166,38 @@ 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
|
||||
|
||||
# Authentication settings
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
LOGOUT_REDIRECT_URL = 'login'
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
"OPTIONS": {
|
||||
"min_length": 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
#LOGGING = {
|
||||
# "version": 1,
|
||||
# "handlers" :{
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.contrib import admin
|
||||
from django.urls import include, path, re_path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib.auth import views as auth_views
|
||||
import dokumente.views
|
||||
import pages.views
|
||||
import referenzen.views
|
||||
@@ -32,11 +33,14 @@ urlpatterns = [
|
||||
path('stichworte/', include("stichworte.urls")),
|
||||
path('referenzen/', referenzen.views.tree, name="referenz_tree"),
|
||||
path('referenzen/<str:refid>/', referenzen.views.detail, name="referenz_detail"),
|
||||
# Authentication URLs
|
||||
path('login/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(next_page='/'), name='logout'),
|
||||
path('password_change/', auth_views.PasswordChangeView.as_view(template_name='registration/password_change.html', success_url='/'), name='password_change'),
|
||||
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)
|
||||
|
||||
@@ -467,6 +467,32 @@ A -> B
|
||||
typ, html = result[0]
|
||||
self.assertEqual(typ, "text")
|
||||
|
||||
def test_render_textabschnitte_xss_prevention(self):
|
||||
"""Test that malicious HTML is sanitized in rendered content"""
|
||||
from dokumente.models import VorgabeLangtext
|
||||
|
||||
# Create content with malicious HTML
|
||||
malicious_abschnitt = VorgabeLangtext.objects.create(
|
||||
abschnitt=self.vorgabe,
|
||||
abschnitttyp=self.typ_text,
|
||||
inhalt='<script>alert("xss")</script><img src=x onerror=alert(1)>Normal text',
|
||||
order=1
|
||||
)
|
||||
|
||||
result = render_textabschnitte(VorgabeLangtext.objects.filter(pk=malicious_abschnitt.pk))
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
typ, html = result[0]
|
||||
self.assertEqual(typ, "text")
|
||||
|
||||
# Dangerous tags and attributes should be removed or sanitized
|
||||
self.assertNotIn('<script>', html) # Script tags should not be present unescaped
|
||||
self.assertNotIn('onerror', html) # Dangerous attributes removed
|
||||
# Note: 'alert' may still be present in escaped script tags, which is safe
|
||||
|
||||
# Safe content should remain
|
||||
self.assertIn('Normal text', html)
|
||||
|
||||
|
||||
class MdTableToHtmlTest(TestCase):
|
||||
"""Test cases for md_table_to_html function"""
|
||||
|
||||
@@ -4,12 +4,34 @@ import zlib
|
||||
import re
|
||||
from textwrap import dedent
|
||||
from django.conf import settings
|
||||
import bleach
|
||||
|
||||
# Import the caching function
|
||||
from diagramm_proxy.diagram_cache import get_cached_diagram
|
||||
|
||||
DIAGRAMMSERVER="/diagramm"
|
||||
|
||||
# Allowed HTML tags for bleach sanitization
|
||||
ALLOWED_TAGS = [
|
||||
'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'hr',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'img', 'a', 'sup', 'sub', 'span', 'div'
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'img': ['src', 'alt', 'width', 'height'],
|
||||
'a': ['href', 'title'],
|
||||
'span': ['class'],
|
||||
'div': ['class'],
|
||||
'p': ['class'],
|
||||
'table': ['class'],
|
||||
'th': ['colspan', 'rowspan', 'class'],
|
||||
'td': ['colspan', 'rowspan', 'class'],
|
||||
'pre': ['class'],
|
||||
'code': ['class'],
|
||||
}
|
||||
|
||||
def render_textabschnitte(queryset):
|
||||
"""
|
||||
Converts a queryset of Textabschnitt-like models into a list of (typ, html) tuples.
|
||||
@@ -52,6 +74,8 @@ def render_textabschnitte(queryset):
|
||||
html += "</code></pre>"
|
||||
else:
|
||||
html = markdown(inhalt, extensions=['tables', 'attr_list','footnotes'])
|
||||
# Sanitize HTML to prevent XSS
|
||||
html = bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
|
||||
output.append((typ, html))
|
||||
return output
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ metadata:
|
||||
namespace: vorgabenui
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
- ReadWriteMany
|
||||
storageClassName: nfs
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
|
||||
25
argocd/configmap.yaml
Normal file
25
argocd/configmap.yaml
Normal 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"
|
||||
@@ -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.9
|
||||
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ]
|
||||
image: git.baumann.gr/adebaumann/vui-data-loader:0.11
|
||||
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.952-oblique
|
||||
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:
|
||||
@@ -63,6 +88,8 @@ spec:
|
||||
selector:
|
||||
app: django
|
||||
ports:
|
||||
- port: 8000
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: 8000
|
||||
targetPort: 8000
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ metadata:
|
||||
name: django
|
||||
namespace: vorgabenui
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: "vorgabenui-vorgabenui-rewrite@kubernetescrd"
|
||||
argocd.argoproj.io/ignore-healthcheck: "true"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: vorgabenportal.knowyoursecurity.com
|
||||
http:
|
||||
|
||||
18
argocd/nfs-pv.yaml
Normal file
18
argocd/nfs-pv.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: django-data-pv
|
||||
namespace: vorgabenui
|
||||
spec:
|
||||
claimRef:
|
||||
name: django-data-pvc
|
||||
namespace: vorgabenui
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: nfs
|
||||
nfs:
|
||||
server: 192.168.17.199
|
||||
path: /mnt/user/vorgabenui
|
||||
8
argocd/nfs-storageclass.yaml
Normal file
8
argocd/nfs-storageclass.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: nfs
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
allowVolumeExpansion: true
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: Immediate
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: traefik.containo.us/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: vorgabenui-rewrite
|
||||
namespace: vorgabenui
|
||||
spec:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/"
|
||||
512
code-review.md
Normal file
512
code-review.md
Normal 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*
|
||||
Binary file not shown.
BIN
data/db.sqlite3
BIN
data/db.sqlite3
Binary file not shown.
383
docs/kubernetes-secrets.md
Normal file
383
docs/kubernetes-secrets.md
Normal 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.
|
||||
@@ -94,9 +94,17 @@ class EinleitungInline(NestedStackedInline):
|
||||
|
||||
class VorgabeForm(forms.ModelForm):
|
||||
referenzen = TreeNodeMultipleChoiceField(queryset=Referenz.objects.all(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = Vorgabe
|
||||
fields = '__all__'
|
||||
|
||||
def clean_thema(self):
|
||||
"""Validate that thema is provided."""
|
||||
thema = self.cleaned_data.get('thema')
|
||||
if not thema:
|
||||
raise forms.ValidationError('Thema ist ein Pflichtfeld. Bitte wählen Sie ein Thema aus.')
|
||||
return thema
|
||||
|
||||
class VorgabeInline(SortableInlineAdminMixin, NestedStackedInline):
|
||||
model = Vorgabe
|
||||
@@ -293,6 +301,6 @@ class VorgabeAdmin(NestedModelAdmin):
|
||||
|
||||
admin.site.register(Checklistenfrage)
|
||||
admin.site.register(Dokumententyp)
|
||||
#admin.site.register(Person)
|
||||
admin.site.register(VorgabeComment)
|
||||
|
||||
#admin.site.register(Changelog)
|
||||
|
||||
39
dokumente/management/commands/export_xml.py
Normal file
39
dokumente/management/commands/export_xml.py
Normal 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)
|
||||
@@ -71,6 +71,7 @@ class Command(BaseCommand):
|
||||
"name": name,
|
||||
"gueltigkeit_von": options["gueltigkeit_von"],
|
||||
"gueltigkeit_bis": options["gueltigkeit_bis"],
|
||||
"aktiv":False,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
@@ -319,6 +320,7 @@ class Command(BaseCommand):
|
||||
thema=thema,
|
||||
titel=v["titel"],
|
||||
gueltigkeit_von=timezone.now().date(),
|
||||
order=0,
|
||||
)
|
||||
|
||||
# Stichworte
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.5 on 2025-11-27 22:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dokumente', '0009_alter_vorgabe_options_vorgabe_order'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VorgabenTable',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Vorgabe (Tabellenansicht)',
|
||||
'verbose_name_plural': 'Vorgaben (Tabellenansicht)',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('dokumente.vorgabe',),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='person',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'Personen'},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VorgabeComment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('vorgabe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='dokumente.vorgabe')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Vorgabe-Kommentar',
|
||||
'verbose_name_plural': 'Vorgabe-Kommentare',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from django.contrib.auth.models import User
|
||||
from abschnitte.models import Textabschnitt
|
||||
from stichworte.models import Stichwort
|
||||
from referenzen.models import Referenz
|
||||
@@ -11,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"
|
||||
@@ -27,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)
|
||||
@@ -36,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)
|
||||
@@ -48,11 +50,39 @@ 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}"
|
||||
|
||||
@property
|
||||
def dates(self):
|
||||
"""
|
||||
Returns an array of unique, chronologically sorted dates representing
|
||||
state-change dates from all Vorgaben in this document.
|
||||
|
||||
These are dates where Vorgaben become active (gueltigkeit_von) or change state
|
||||
(the day after gueltigkeit_bis). The very last date in the list is excluded
|
||||
as it has no relevance (nothing changes after it).
|
||||
"""
|
||||
dates_set = set()
|
||||
|
||||
# Get all vorgaben for this document
|
||||
for vorgabe in self.vorgaben.all():
|
||||
# Add gueltigkeit_von (when vorgabe becomes active)
|
||||
if vorgabe.gueltigkeit_von:
|
||||
dates_set.add(vorgabe.gueltigkeit_von)
|
||||
|
||||
# Add the day after gueltigkeit_bis (when vorgabe expires/changes state)
|
||||
# Only if gueltigkeit_bis is defined (not None)
|
||||
if vorgabe.gueltigkeit_bis:
|
||||
dates_set.add(vorgabe.gueltigkeit_bis + datetime.timedelta(days=1))
|
||||
|
||||
# Return sorted unique dates from oldest to newest, excluding the last date
|
||||
# (but only if there are multiple dates; single dates are kept)
|
||||
sorted_dates = sorted(list(dates_set))
|
||||
return sorted_dates[:-1] if len(sorted_dates) > 1 else sorted_dates
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural="Dokumente"
|
||||
verbose_name="Dokument"
|
||||
@@ -140,6 +170,12 @@ class Vorgabe(models.Model):
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Check that thema is provided
|
||||
if not self.thema_id:
|
||||
raise ValidationError({
|
||||
'thema': 'Thema ist ein Pflichtfeld. Bitte wählen Sie ein Thema aus.'
|
||||
})
|
||||
|
||||
# Check for conflicts with existing Vorgaben
|
||||
conflicts = self.find_conflicts()
|
||||
if conflicts:
|
||||
@@ -261,3 +297,19 @@ class Changelog(models.Model):
|
||||
class Meta:
|
||||
verbose_name_plural="Changelog"
|
||||
verbose_name="Changelog-Eintrag"
|
||||
|
||||
|
||||
class VorgabeComment(models.Model):
|
||||
vorgabe = models.ForeignKey(Vorgabe, on_delete=models.CASCADE, related_name='comments')
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Vorgabe-Kommentar"
|
||||
verbose_name_plural = "Vorgabe-Kommentare"
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Kommentar von {self.user.username} zu {self.vorgabe.Vorgabennummer()}"
|
||||
|
||||
67
dokumente/templates/standards/all_comments.html
Normal file
67
dokumente/templates/standards/all_comments.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Alle Kommentare</h1>
|
||||
|
||||
{% if total_comments == 0 %}
|
||||
<div class="alert alert-info">
|
||||
<p>Es gibt noch keine Kommentare zu Vorgaben.</p>
|
||||
<p><a href="{% url 'standard_list' %}">Zu den Standards</a></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Insgesamt {{ total_comments }} Kommentar{{ total_comments|pluralize:"e" }}</p>
|
||||
|
||||
{% for dokument, comments in comments_by_document.items %}
|
||||
<div class="panel panel-default" style="margin-top: 2rem;">
|
||||
<div class="panel-heading">
|
||||
<h2 style="margin: 0;">
|
||||
<a href="{% url 'standard_detail' nummer=dokument.nummer %}">
|
||||
{{ dokument.nummer }} – {{ dokument.name }}
|
||||
</a>
|
||||
</h2>
|
||||
<p style="margin: 0; color: #666; font-size: 0.9rem;">
|
||||
{{ comments|length }} Kommentar{{ comments|length|pluralize:"e" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="list-group">
|
||||
{% for comment in comments %}
|
||||
<div class="list-group-item" style="border-left: 3px solid #007bff; padding: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="flex: 1;">
|
||||
<h4 style="margin: 0 0 0.5rem 0;">
|
||||
<a href="{% url 'standard_detail' nummer=comment.vorgabe.dokument.nummer %}#{{ comment.vorgabe.Vorgabennummer }}">
|
||||
{{ comment.vorgabe.Vorgabennummer }}
|
||||
</a> {{ comment.vorgabe.titel }}
|
||||
</h4>
|
||||
<p style="margin: 0 0 0.75rem 0; color: #666; font-size: 0.9rem;">
|
||||
<strong>Benutzer:</strong> {{ comment.user.first_name }} {{ comment.user.last_name }}<br>
|
||||
<strong>Erstellt:</strong> {{ comment.created_at|date:"d.m.Y H:i" }}
|
||||
{% if comment.updated_at != comment.created_at %}
|
||||
<br>
|
||||
<strong>Bearbeitet:</strong> {{ comment.updated_at|date:"d.m.Y H:i" }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="{% url 'delete_vorgabe_comment' comment.id %}"
|
||||
style="display: inline; margin-left: 1rem;"
|
||||
onsubmit="return confirm('Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="background: #f8f9fa; padding: 0.75rem; border-radius: 4px; margin-top: 0.5rem; white-space: pre-wrap; word-wrap: break-word;">
|
||||
{{ comment.text }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid #ddd;">
|
||||
<a href="{% url 'standard_list' %}" class="btn btn-default">Zu den Standards</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -105,13 +105,13 @@
|
||||
{% else %}
|
||||
<div class="alert alert-success" role="alert">
|
||||
<h4 class="alert-heading">
|
||||
<i class="fas fa-check-circle"></i> Alle Vorgaben sind vollständig!
|
||||
<span class="emoji-icon">✅</span> Alle Vorgaben sind vollständig!
|
||||
</h4>
|
||||
<p>Alle Vorgaben haben Referenzen, Stichworte, Text und Checklistenfragen.</p>
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<a href="{% url 'standard_list' %}" class="btn btn-primary">
|
||||
<i class="fas fa-list"></i> Zurück zur Übersicht
|
||||
<span class="emoji-icon">📋</span> Zurück zur Übersicht
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{% url 'standard_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
|
||||
<span class="emoji-icon">←</span> Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,32 +16,31 @@
|
||||
|
||||
{% if standard.history == True %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
{% if standard.is_future %}
|
||||
<strong>Zukünftige Version vom {{ standard.check_date }}</strong>
|
||||
{% else %}
|
||||
<strong>Historische Version vom {{ standard.check_date }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Metadata -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- History Dates Dropdown -->
|
||||
{% if standard.dates %}
|
||||
<div class="mb-3">
|
||||
<div class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" style="text-decoration: none;">
|
||||
📅 Historische Versionen
|
||||
</a>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li><a href="/dokumente/{{ standard.nummer }}/">Aktuelle Version</a></li>
|
||||
<li class="divider"></li>
|
||||
{% for date in standard.dates %}
|
||||
<li><a href="/dokumente/{{ standard.nummer }}/history/{{ date|date:'Y-m-d' }}/">{{ date|date:'d.m.Y' }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Einleitung -->
|
||||
{% if standard.einleitung_html %}
|
||||
@@ -49,7 +48,7 @@
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="h4 mb-0">Einleitung</h2>
|
||||
<h2>Einleitung</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for typ, html in standard.einleitung_html %}
|
||||
@@ -67,7 +66,7 @@
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="h4 mb-0">Geltungsbereich</h2>
|
||||
<h2>Geltungsbereich</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for typ, html in standard.geltungsbereich_html %}
|
||||
@@ -97,7 +96,7 @@
|
||||
<a id="{{ vorgabe.Vorgabennummer }}"></a>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 class="h5 mb-0">
|
||||
<h3>
|
||||
{{ vorgabe.Vorgabennummer }} – {{ vorgabe.titel }}
|
||||
{% if vorgabe.long_status != "active" and standard.history == True %}
|
||||
<span class="badge badge-danger">{{ vorgabe.long_status }}</span>
|
||||
@@ -147,7 +146,7 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Stichworte und Referenzen -->
|
||||
<div class="mt-4 p-3" style="background-color: #f8f9fa; border-left: 3px solid #dee2e6;">
|
||||
<div class="mt-4 p-3" style="background-color: #f8f9fa; border-left: 3px solid #dee2e6; padding-left: 0.5en;">
|
||||
<p class="mb-2">
|
||||
<strong>Stichworte:</strong>
|
||||
{% if vorgabe.stichworte.all %}
|
||||
@@ -169,11 +168,260 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Comment Button -->
|
||||
{% if user.is_authenticated %}
|
||||
<div class="mt-3 text-right">
|
||||
<button class="btn btn-sm btn-outline-primary comment-btn"
|
||||
data-vorgabe-id="{{ vorgabe.id }}"
|
||||
data-vorgabe-nummer="{{ vorgabe.Vorgabennummer }}">
|
||||
<span class="emoji-icon">💬</span> Kommentare
|
||||
{% if vorgabe.comment_count > 0 %}
|
||||
<span class="comment-count">{{ vorgabe.comment_count }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Metadata -->
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Comment Modal -->
|
||||
<div class="modal fade" id="commentModal" tabindex="-1" role="dialog" aria-labelledby="commentModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="commentModalLabel">Kommentare für <span id="modalVorgabeNummer"></span></h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="commentsContainer">
|
||||
<!-- Comments will be loaded here -->
|
||||
</div>
|
||||
|
||||
<!-- Add Comment Form -->
|
||||
<div class="mt-4">
|
||||
<h6>Neuen Kommentar hinzufügen:</h6>
|
||||
<textarea id="newCommentText" class="form-control" rows="3" placeholder="Ihr Kommentar..."></textarea>
|
||||
<button id="addCommentBtn" class="btn btn-primary btn-sm mt-2">Kommentar hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for Comments -->
|
||||
<script>
|
||||
// Content Security Policy for comment system
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Prevent inline script execution in dynamically loaded content
|
||||
const commentsContainer = document.getElementById('commentsContainer');
|
||||
if (commentsContainer) {
|
||||
// Use DOMPurify-like approach - only allow safe HTML
|
||||
const allowedTags = ['br', 'small', 'div', 'span', 'button'];
|
||||
const allowedAttributes = ['class', 'data-comment-id', 'aria-hidden'];
|
||||
|
||||
// Monitor for any script injection attempts
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === 1) { // Element node
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === 'script') {
|
||||
console.warn('Script injection attempt blocked');
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(commentsContainer, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let currentVorgabeId = null;
|
||||
let currentVorgabeNummer = null;
|
||||
|
||||
// Comment button click handler
|
||||
document.querySelectorAll('.comment-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
currentVorgabeId = this.dataset.vorgabeId;
|
||||
currentVorgabeNummer = this.dataset.vorgabeNummer;
|
||||
|
||||
document.getElementById('modalVorgabeNummer').textContent = currentVorgabeNummer;
|
||||
document.getElementById('newCommentText').value = '';
|
||||
|
||||
loadComments();
|
||||
$('#commentModal').modal('show');
|
||||
});
|
||||
});
|
||||
|
||||
// Load comments function
|
||||
function loadComments() {
|
||||
fetch(`/dokumente/comments/${currentVorgabeId}/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
renderComments(data.comments);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading comments:', error);
|
||||
document.getElementById('commentsContainer').innerHTML =
|
||||
'<div class="alert alert-danger">Fehler beim Laden der Kommentare</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Render comments function
|
||||
function renderComments(comments) {
|
||||
const container = document.getElementById('commentsContainer');
|
||||
|
||||
if (comments.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">Noch keine Kommentare vorhanden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
comments.forEach(comment => {
|
||||
const canDelete = comment.is_own || {% if user.is_authenticated %}'{{ user.is_staff|yesno:"true,false" }}'{% else %}'false'{% endif %} === 'true';
|
||||
html += `
|
||||
<div class="comment-item border-bottom pb-2 mb-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<strong>${comment.user}</strong>
|
||||
<small class="text-muted">(${comment.created_at})</small>
|
||||
${comment.updated_at !== comment.created_at ? `<small class="text-muted">(bearbeitet: ${comment.updated_at})</small>` : ''}
|
||||
<div class="mt-1">${comment.text}</div>
|
||||
</div>
|
||||
${canDelete ? `
|
||||
<button class="btn btn-sm btn-outline-danger ml-2 delete-comment-btn" data-comment-id="${comment.id}">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add delete handlers
|
||||
document.querySelectorAll('.delete-comment-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
if (confirm('Möchten Sie diesen Kommentar wirklich löschen?')) {
|
||||
deleteComment(this.dataset.commentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add comment function
|
||||
document.getElementById('addCommentBtn').addEventListener('click', function() {
|
||||
const text = document.getElementById('newCommentText').value.trim();
|
||||
|
||||
if (!text) {
|
||||
alert('Bitte geben Sie einen Kommentar ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/dokumente/comments/${currentVorgabeId}/add/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify({ text: text })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('newCommentText').value = '';
|
||||
loadComments();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error adding comment:', error);
|
||||
alert('Fehler beim Hinzufügen des Kommentars');
|
||||
});
|
||||
});
|
||||
|
||||
// Delete comment function
|
||||
function deleteComment(commentId) {
|
||||
fetch(`/dokumente/comments/delete/${commentId}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadComments();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting comment:', error);
|
||||
alert('Fehler beim Löschen des Kommentars');
|
||||
});
|
||||
}
|
||||
|
||||
// CSRF token helper
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
66
dokumente/templates/standards/user_comments.html
Normal file
66
dokumente/templates/standards/user_comments.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Meine Kommentare</h1>
|
||||
|
||||
{% if total_comments == 0 %}
|
||||
<div class="alert alert-info">
|
||||
<p>Sie haben noch keine Kommentare zu Vorgaben hinterlassen.</p>
|
||||
<p><a href="{% url 'standard_list' %}">Zu den Standards</a></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Insgesamt {{ total_comments }} Kommentar{{ total_comments|pluralize:"e" }}</p>
|
||||
|
||||
{% for dokument, comments in comments_by_document.items %}
|
||||
<div class="panel panel-default" style="margin-top: 2rem;">
|
||||
<div class="panel-heading">
|
||||
<h2 style="margin: 0;">
|
||||
<a href="{% url 'standard_detail' nummer=dokument.nummer %}">
|
||||
{{ dokument.nummer }} – {{ dokument.name }}
|
||||
</a>
|
||||
</h2>
|
||||
<p style="margin: 0; color: #666; font-size: 0.9rem;">
|
||||
{{ comments|length }} Kommentar{{ comments|length|pluralize:"e" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="list-group">
|
||||
{% for comment in comments %}
|
||||
<div class="list-group-item" style="border-left: 3px solid #007bff; padding: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="flex: 1;">
|
||||
<h4 style="margin: 0 0 0.5rem 0;">
|
||||
<a href="{% url 'standard_detail' nummer=comment.vorgabe.dokument.nummer %}#{{ comment.vorgabe.Vorgabennummer }}">
|
||||
{{ comment.vorgabe.Vorgabennummer }}
|
||||
</a> {{ comment.vorgabe.titel }}
|
||||
</h4>
|
||||
<p style="margin: 0 0 0.75rem 0; color: #666; font-size: 0.9rem;">
|
||||
<strong>Erstellt:</strong> {{ comment.created_at|date:"d.m.Y H:i" }}
|
||||
{% if comment.updated_at != comment.created_at %}
|
||||
<br>
|
||||
<strong>Bearbeitet:</strong> {{ comment.updated_at|date:"d.m.Y H:i" }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="{% url 'delete_vorgabe_comment' comment.id %}"
|
||||
style="display: inline; margin-left: 1rem;"
|
||||
onsubmit="return confirm('Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="background: #f8f9fa; padding: 0.75rem; border-radius: 4px; margin-top: 0.5rem; white-space: pre-wrap; word-wrap: break-word;">
|
||||
{{ comment.text }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid #ddd;">
|
||||
<a href="{% url 'standard_list' %}" class="btn btn-default">Zu den Standards</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
960
dokumente/test_import_command.py
Normal file
960
dokumente/test_import_command.py
Normal 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)
|
||||
1989
dokumente/tests.py
1989
dokumente/tests.py
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,16 @@ from . import views
|
||||
urlpatterns = [
|
||||
path('', views.standard_list, name='standard_list'),
|
||||
path('unvollstaendig/', views.incomplete_vorgaben, name='incomplete_vorgaben'),
|
||||
path('meine-kommentare/', views.user_comments, name='user_comments'),
|
||||
path('alle-kommentare/', views.all_comments, name='all_comments'),
|
||||
path('<str:nummer>/', views.standard_detail, name='standard_detail'),
|
||||
path('<str:nummer>/history/<str:check_date>/', views.standard_detail),
|
||||
path('<str:nummer>/history/', views.standard_detail, {"check_date":"today"}, name='standard_history'),
|
||||
path('<str:nummer>/checkliste/', views.standard_checkliste, name='standard_checkliste'),
|
||||
path('<str:nummer>/json/', views.standard_json, name='standard_json')
|
||||
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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -119,5 +121,192 @@ def format_conflict_report(conflicts, verbose=False):
|
||||
lines.append(f" Overlap: {overlap_start} to {overlap_end}")
|
||||
else:
|
||||
lines.append(f" Overlap starts: {overlap_start} (no end)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
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
|
||||
@@ -1,9 +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
|
||||
from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage
|
||||
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
|
||||
@@ -25,9 +31,11 @@ def standard_detail(request, nummer,check_date=""):
|
||||
if check_date:
|
||||
check_date = calendar.parseDT(check_date)[0].date()
|
||||
standard.history = True
|
||||
standard.is_future = check_date > date.today()
|
||||
else:
|
||||
check_date = date.today()
|
||||
standard.history = False
|
||||
standard.is_future = False
|
||||
standard.check_date=check_date
|
||||
vorgaben = list(standard.vorgaben.order_by("thema","nummer").select_related("thema","dokument")) # convert queryset to list so we can attach attributes
|
||||
|
||||
@@ -44,6 +52,15 @@ def standard_detail(request, nummer,check_date=""):
|
||||
for r in vorgabe.referenzen.all():
|
||||
referenz_items.append(r.Path())
|
||||
vorgabe.referenzpfade = referenz_items
|
||||
|
||||
# Add comment count
|
||||
if request.user.is_authenticated:
|
||||
if request.user.is_staff:
|
||||
vorgabe.comment_count = vorgabe.comments.count()
|
||||
else:
|
||||
vorgabe.comment_count = vorgabe.comments.filter(user=request.user).count()
|
||||
else:
|
||||
vorgabe.comment_count = 0
|
||||
|
||||
return render(request, 'standards/standard_detail.html', {
|
||||
'standard': standard,
|
||||
@@ -237,3 +254,204 @@ def standard_json(request, nummer):
|
||||
|
||||
# Return JSON response
|
||||
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"""
|
||||
vorgabe = get_object_or_404(Vorgabe, id=vorgabe_id)
|
||||
|
||||
if request.user.is_staff:
|
||||
# Staff can see all comments
|
||||
comments = vorgabe.comments.all().select_related('user').order_by('created_at')
|
||||
else:
|
||||
# Regular users can only see their own comments
|
||||
comments = vorgabe.comments.filter(user=request.user).select_related('user').order_by('created_at')
|
||||
|
||||
comments_data = []
|
||||
for comment in comments:
|
||||
# Escape HTML but preserve line breaks
|
||||
escaped_text = escape(comment.text).replace('\n', '<br>')
|
||||
comments_data.append({
|
||||
'id': comment.id,
|
||||
'text': escaped_text,
|
||||
'user': escape(comment.user.first_name+" "+comment.user.last_name),
|
||||
'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'),
|
||||
'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'),
|
||||
'is_own': comment.user == request.user
|
||||
})
|
||||
|
||||
response = JsonResponse({'comments': comments_data})
|
||||
response['Content-Security-Policy'] = "default-src 'self'"
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def add_vorgabe_comment(request, vorgabe_id):
|
||||
"""Add a new comment to a Vorgabe"""
|
||||
vorgabe = get_object_or_404(Vorgabe, id=vorgabe_id)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
text = data.get('text', '').strip()
|
||||
|
||||
# Validate input
|
||||
if not text:
|
||||
return JsonResponse({'error': 'Kommentar darf nicht leer sein'}, status=400)
|
||||
|
||||
if len(text) > 2000: # Reasonable length limit
|
||||
return JsonResponse({'error': 'Kommentar ist zu lang (max 2000 Zeichen)'}, status=400)
|
||||
|
||||
# Additional XSS prevention - check for dangerous patterns
|
||||
dangerous_patterns = ['<script', 'javascript:', 'onload=', 'onerror=', 'onclick=', 'onmouseover=']
|
||||
text_lower = text.lower()
|
||||
for pattern in dangerous_patterns:
|
||||
if pattern in text_lower:
|
||||
return JsonResponse({'error': 'Kommentar enthält ungültige Zeichen'}, status=400)
|
||||
|
||||
comment = VorgabeComment.objects.create(
|
||||
vorgabe=vorgabe,
|
||||
user=request.user,
|
||||
text=text
|
||||
)
|
||||
|
||||
# Escape HTML but preserve line breaks
|
||||
escaped_text = escape(comment.text).replace('\n', '<br>')
|
||||
response = JsonResponse({
|
||||
'success': True,
|
||||
'comment': {
|
||||
'id': comment.id,
|
||||
'text': escaped_text,
|
||||
'user': escape(comment.user.username),
|
||||
'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'),
|
||||
'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'),
|
||||
'is_own': True
|
||||
}
|
||||
})
|
||||
response['Content-Security-Policy'] = "default-src 'self'"
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
|
||||
except json.JSONDecodeError:
|
||||
response = JsonResponse({'error': 'Ungültige Daten'}, status=400)
|
||||
response['Content-Security-Policy'] = "default-src 'self'"
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
except Exception as e:
|
||||
response = JsonResponse({'error': 'Serverfehler'}, status=500)
|
||||
response['Content-Security-Policy'] = "default-src 'self'"
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_vorgabe_comment(request, comment_id):
|
||||
"""Delete a comment (only own comments or staff can delete)"""
|
||||
comment = get_object_or_404(VorgabeComment, id=comment_id)
|
||||
|
||||
# Check if user can delete this comment
|
||||
if comment.user != request.user and not request.user.is_staff:
|
||||
response = JsonResponse({'error': 'Keine Berechtigung zum Löschen dieses Kommentars'}, status=403)
|
||||
response['Content-Security-Policy'] = "default-src 'self'"
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
|
||||
try:
|
||||
comment.delete()
|
||||
response = JsonResponse({'success': True})
|
||||
response['Content-Security-Policy'] = "default-src 'self'"
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
except Exception as e:
|
||||
response = JsonResponse({'error': 'Serverfehler'}, status=500)
|
||||
response['Content-Security-Policy'] = "default-src 'self'"
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def user_comments(request):
|
||||
"""
|
||||
Display all comments made by the logged-in user, grouped by document.
|
||||
"""
|
||||
# Get all comments by the current user
|
||||
user_comments = VorgabeComment.objects.filter(
|
||||
user=request.user
|
||||
).select_related('vorgabe', 'vorgabe__dokument').order_by(
|
||||
'vorgabe__dokument__nummer', '-created_at'
|
||||
)
|
||||
|
||||
# Group comments by document
|
||||
comments_by_document = {}
|
||||
for comment in user_comments:
|
||||
dokument = comment.vorgabe.dokument
|
||||
if dokument not in comments_by_document:
|
||||
comments_by_document[dokument] = []
|
||||
comments_by_document[dokument].append(comment)
|
||||
|
||||
return render(request, 'standards/user_comments.html', {
|
||||
'comments_by_document': comments_by_document,
|
||||
'total_comments': user_comments.count(),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(is_staff_user)
|
||||
def all_comments(request):
|
||||
"""
|
||||
Display all comments from all users, grouped by document.
|
||||
Staff only.
|
||||
"""
|
||||
# Get all comments
|
||||
all_comments_qs = VorgabeComment.objects.select_related(
|
||||
'vorgabe', 'vorgabe__dokument', 'user'
|
||||
).order_by(
|
||||
'vorgabe__dokument__nummer', '-created_at'
|
||||
)
|
||||
|
||||
# Group comments by document
|
||||
comments_by_document = {}
|
||||
for comment in all_comments_qs:
|
||||
dokument = comment.vorgabe.dokument
|
||||
if dokument not in comments_by_document:
|
||||
comments_by_document[dokument] = []
|
||||
comments_by_document[dokument].append(comment)
|
||||
|
||||
return render(request, 'standards/all_comments.html', {
|
||||
'comments_by_document': comments_by_document,
|
||||
'total_comments': all_comments_qs.count(),
|
||||
})
|
||||
|
||||
@@ -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.
@@ -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.917
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
12
k8s/pvc.yaml
12
k8s/pvc.yaml
@@ -1,12 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: django-data-pvc
|
||||
namespace: vorgabenui
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: traefik.containo.us/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: vorgabenui-rewrite
|
||||
namespace: vorgabenui
|
||||
spec:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/"
|
||||
5
openspec/changes/archive/2025-11-24-add-login/tasks.md
Normal file
5
openspec/changes/archive/2025-11-24-add-login/tasks.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## 1. Add user login functionality
|
||||
- [x] add a login screen for users
|
||||
- [x] add an icon for logged in user on the top right corner of all page
|
||||
- [x] add a menu to log out and change password on the user icon
|
||||
- [x] all functions should go back to the main page, not the django admin page
|
||||
63
openspec/project.md
Normal file
63
openspec/project.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Project Context
|
||||
|
||||
## Purpose
|
||||
This is a Django-based document management system for regulatory documents (Dokumente) and their provisions (Vorgaben). It manages validity periods, conflicts between overlapping provisions, references, keywords, and roles. The system supports importing documents, checking for compliance, and maintaining changelogs.
|
||||
|
||||
## Tech Stack
|
||||
- Python 3.x
|
||||
- Django 5.2.5
|
||||
- SQLite (development), PostgreSQL (production)
|
||||
- Django MPTT for tree structures
|
||||
- Django Nested Admin for inline editing
|
||||
- Kubernetes for deployment
|
||||
- ArgoCD for continuous deployment
|
||||
- Traefik for ingress
|
||||
- Gunicorn for WSGI server
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Code Style
|
||||
- Language: German for user-facing strings and model names, English for code comments and internal naming
|
||||
- Imports: Standard library first, then Django, then third-party, then local apps
|
||||
- Model naming: German nouns (Dokument, Vorgabe, Person)
|
||||
- Field naming: German for field names, English Django conventions
|
||||
- Class naming: PascalCase for models, snake_case for functions/variables
|
||||
- All models have __str__ methods returning meaningful German strings
|
||||
- Use verbose_name and verbose_name_plural in Meta classes (German)
|
||||
|
||||
### Architecture Patterns
|
||||
- Django apps: abschnitte, dokumente, referenzen, rollen, stichworte, pages
|
||||
- MPTT for hierarchical text sections
|
||||
- Foreign keys with on_delete=models.PROTECT for important relationships
|
||||
- Many-to-many with descriptive related_name
|
||||
- Proxy models for different views (e.g., VorgabenTable)
|
||||
- Management commands for data operations
|
||||
|
||||
### Testing Strategy
|
||||
- Django test framework
|
||||
- Test class names in English, methods in English
|
||||
- Comprehensive model tests
|
||||
- Test both success and error cases
|
||||
- Run with `python manage.py test`
|
||||
|
||||
### Git Workflow
|
||||
- Standard Git workflow
|
||||
- Commits in English
|
||||
- Use Gitea workflows for CI/CD
|
||||
|
||||
## Domain Context
|
||||
The system manages regulatory documents with numbered provisions that have validity dates. Provisions can conflict if they have overlapping date ranges for the same document, theme, and number. The system includes sanity checks for conflicts, diagram caching for visualization, and JSON export functionality.
|
||||
|
||||
## Important Constraints
|
||||
- German language for all user interfaces and data
|
||||
- Strict validation of date ranges to prevent overlapping provisions
|
||||
- Documents have types, authors, reviewers, and validity periods
|
||||
- Provisions linked to themes, references, keywords, and relevant roles
|
||||
- Active/inactive status for documents
|
||||
|
||||
## External Dependencies
|
||||
- Django ecosystem: MPTT, nested-admin, revproxy
|
||||
- Kubernetes cluster for deployment
|
||||
- ArgoCD for GitOps
|
||||
- Traefik for load balancing
|
||||
- External diagram services (diagramm_proxy)
|
||||
16
pages/templates/400.html
Normal file
16
pages/templates/400.html
Normal 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
21
pages/templates/403.html
Normal 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
21
pages/templates/404.html
Normal 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
16
pages/templates/500.html
Normal 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 %}
|
||||
@@ -41,6 +41,41 @@
|
||||
alt="Zur Startseite" />
|
||||
<h1>Vorgaben Informatiksicherheit BIT</h1>
|
||||
</a>
|
||||
|
||||
<!-- User Menu -->
|
||||
{% if user.is_authenticated %}
|
||||
<div class="user-menu" style="position: absolute; top: 20px; right: 20px; z-index: 1000;">
|
||||
<div class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" style="text-decoration: none; color: #000; display: flex; align-items: center;">
|
||||
<span style="font-size: 24px; margin-right: 8px;">👤</span>
|
||||
<span class="hidden-xs" style="margin-left: 0;">{{ user.first_name }} {{ user.last_name }}</span>
|
||||
<span class="caret" style="margin-left: 8px;"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-right" role="menu">
|
||||
<li><a href="{% url 'user_comments' %}">Meine Kommentare</a></li>
|
||||
{% if user.is_staff %}
|
||||
<li><a href="{% url 'all_comments' %}">Alle Kommentare</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'password_change' %}">Passwort ändern</a></li>
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<form method="post" action="{% url 'logout' %}" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" style="background: none; border: none; color: inherit; padding: 3px 20px; width: 100%; text-align: left; cursor: pointer;">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="user-menu" style="position: absolute; top: 20px; right: 20px; z-index: 1000;">
|
||||
<a href="{% url 'login' %}" class="btn btn-sm btn-primary" style="text-decoration: none;">
|
||||
Anmelden
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<!-- Main Navigation -->
|
||||
@@ -71,6 +106,7 @@
|
||||
<li><a href="/dokumente">Standards</a></li>
|
||||
{% if user.is_staff %}
|
||||
<li><a href="/dokumente/unvollstaendig/">Unvollständig</a></li>
|
||||
<li><a href="/autorenumgebung/">Autor</a></li>
|
||||
{% endif %}
|
||||
<li><a href="/referenzen">Referenzen</a></li>
|
||||
<li><a href="/stichworte">Stichworte</a></li>
|
||||
@@ -100,6 +136,9 @@
|
||||
<li class="dropdown {% if 'unvollstaendig' in request.path %}current{% endif %}">
|
||||
<a href="/dokumente/unvollstaendig/">Unvollständig</a>
|
||||
</li>
|
||||
<li class="dropdown {% if 'autorenumgebung' in request.path %}current{% endif %}">
|
||||
<a href="/autorenumgebung/">Autor</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="dropdown {% if 'referenzen' in request.path %}current{% endif %}">
|
||||
<a href="/referenzen">Referenzen</a>
|
||||
@@ -180,8 +219,8 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-6 text-right">
|
||||
<p class="text-muted">Version {{ version|default:"0.951" }}</p>
|
||||
</div>
|
||||
<p class="text-muted">Version {{ version|default:"0.983" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
43
pages/templates/registration/login.html
Normal file
43
pages/templates/registration/login.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Anmelden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Anmelden</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<p>Ihr Benutzername und Passwort stimmen nicht überein. Bitte versuchen Sie es erneut.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Benutzername:</label>
|
||||
<input type="text" name="username" class="form-control" id="id_username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password">Passwort:</label>
|
||||
<input type="password" name="password" class="form-control" id="id_password" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Anmelden</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
56
pages/templates/registration/password_change.html
Normal file
56
pages/templates/registration/password_change.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Passwort ändern{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Passwort ändern</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<p>Bitte korrigieren Sie die Fehler unten.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_old_password">Aktuelles Passwort:</label>
|
||||
<input type="password" name="old_password" class="form-control" id="id_old_password" required>
|
||||
{% if form.old_password.errors %}
|
||||
<div class="text-danger">{{ form.old_password.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_new_password1">Neues Passwort:</label>
|
||||
<input type="password" name="new_password1" class="form-control" id="id_new_password1" required>
|
||||
{% if form.new_password1.errors %}
|
||||
<div class="text-danger">{{ form.new_password1.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_new_password2">Neues Passwort bestätigen:</label>
|
||||
<input type="password" name="new_password2" class="form-control" id="id_new_password2" required>
|
||||
{% if form.new_password2.errors %}
|
||||
<div class="text-danger">{{ form.new_password2.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Passwort ändern</button>
|
||||
<a href="/" class="btn btn-default">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
pages/templates/registration/password_change_done.html
Normal file
24
pages/templates/registration/password_change_done.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Passwort geändert{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Passwort erfolgreich geändert</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="alert alert-success">
|
||||
<p>Ihr Passwort wurde erfolgreich geändert.</p>
|
||||
</div>
|
||||
<p>
|
||||
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
266
pages/test_auth.py
Normal file
266
pages/test_auth.py
Normal file
@@ -0,0 +1,266 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class AuthenticationTest(TestCase):
|
||||
"""Test login, logout, and password change functionality"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.test_user = User.objects.create_user(
|
||||
username='testuser',
|
||||
password='testpass123',
|
||||
email='test@example.com'
|
||||
)
|
||||
self.staff_user = User.objects.create_user(
|
||||
username='staffuser',
|
||||
password='staffpass123',
|
||||
email='staff@example.com'
|
||||
)
|
||||
self.staff_user.is_staff = True
|
||||
self.staff_user.save()
|
||||
|
||||
def test_login_page_loads(self):
|
||||
"""Test that login page loads correctly"""
|
||||
response = self.client.get(reverse('login'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Anmelden')
|
||||
self.assertContains(response, 'Benutzername:')
|
||||
self.assertContains(response, 'Passwort:')
|
||||
|
||||
def test_login_valid_credentials(self):
|
||||
"""Test successful login with valid credentials"""
|
||||
response = self.client.post(reverse('login'), {
|
||||
'username': 'testuser',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, 302) # Redirect after login
|
||||
self.assertRedirects(response, '/') # Should redirect to main page
|
||||
|
||||
# Check user is logged in
|
||||
response = self.client.get('/')
|
||||
self.assertContains(response, 'testuser') # Username should appear in header
|
||||
|
||||
def test_login_invalid_credentials(self):
|
||||
"""Test login with invalid credentials shows error"""
|
||||
response = self.client.post(reverse('login'), {
|
||||
'username': 'testuser',
|
||||
'password': 'wrongpassword'
|
||||
})
|
||||
self.assertEqual(response.status_code, 200) # Stay on login page
|
||||
self.assertContains(response, 'Ihr Benutzername und Passwort stimmen nicht überein')
|
||||
|
||||
def test_login_empty_credentials(self):
|
||||
"""Test login with empty credentials"""
|
||||
response = self.client.post(reverse('login'), {
|
||||
'username': '',
|
||||
'password': ''
|
||||
})
|
||||
self.assertEqual(response.status_code, 200) # Stay on login page
|
||||
# Django's form validation should handle this
|
||||
|
||||
def test_logout_functionality(self):
|
||||
"""Test logout functionality"""
|
||||
# First login
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
# Verify user is logged in
|
||||
response = self.client.get('/')
|
||||
self.assertContains(response, 'testuser')
|
||||
|
||||
# Logout using POST
|
||||
response = self.client.post(reverse('logout'))
|
||||
self.assertEqual(response.status_code, 302) # Redirect after logout
|
||||
self.assertRedirects(response, '/') # Should redirect to main page
|
||||
|
||||
# Verify user is logged out
|
||||
response = self.client.get('/')
|
||||
self.assertNotContains(response, 'testuser')
|
||||
self.assertContains(response, 'Anmelden') # Should show login link
|
||||
|
||||
def test_logout_requires_post(self):
|
||||
"""Test that logout requires POST method"""
|
||||
# Login first
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
# Try GET logout (should fail with 405)
|
||||
response = self.client.get(reverse('logout'))
|
||||
self.assertEqual(response.status_code, 405) # Method Not Allowed
|
||||
|
||||
# User should still be logged in
|
||||
response = self.client.get('/')
|
||||
self.assertContains(response, 'testuser')
|
||||
|
||||
def test_password_change_page_loads(self):
|
||||
"""Test that password change page loads for authenticated users"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.get(reverse('password_change'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Passwort ändern')
|
||||
self.assertContains(response, 'Aktuelles Passwort:')
|
||||
self.assertContains(response, 'Neues Passwort:')
|
||||
self.assertContains(response, 'Neues Passwort bestätigen:')
|
||||
|
||||
def test_password_change_requires_authentication(self):
|
||||
"""Test that password change page requires authentication"""
|
||||
response = self.client.get(reverse('password_change'))
|
||||
self.assertEqual(response.status_code, 302) # Redirect to login
|
||||
|
||||
# Should redirect to login page
|
||||
self.assertIn(reverse('login'), response.url)
|
||||
|
||||
def test_password_change_valid(self):
|
||||
"""Test successful password change"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.post(reverse('password_change'), {
|
||||
'old_password': 'testpass123',
|
||||
'new_password1': 'newpass456',
|
||||
'new_password2': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, 302) # Redirect after success
|
||||
self.assertRedirects(response, '/') # Should redirect to main page
|
||||
|
||||
# Verify new password works
|
||||
self.client.logout()
|
||||
response = self.client.post(reverse('login'), {
|
||||
'username': 'testuser',
|
||||
'password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, 302) # Successful login
|
||||
|
||||
def test_password_change_wrong_old_password(self):
|
||||
"""Test password change with wrong old password"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.post(reverse('password_change'), {
|
||||
'old_password': 'wrongpassword',
|
||||
'new_password1': 'newpass456',
|
||||
'new_password2': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, 200) # Stay on form page
|
||||
self.assertContains(response, 'Bitte korrigieren Sie die Fehler unten')
|
||||
|
||||
def test_password_change_mismatched_new_passwords(self):
|
||||
"""Test password change with mismatched new passwords"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.post(reverse('password_change'), {
|
||||
'old_password': 'testpass123',
|
||||
'new_password1': 'newpass456',
|
||||
'new_password2': 'differentpass789'
|
||||
})
|
||||
self.assertEqual(response.status_code, 200) # Stay on form page
|
||||
self.assertContains(response, 'Bitte korrigieren Sie die Fehler unten')
|
||||
|
||||
def test_password_change_same_as_old_password(self):
|
||||
"""Test password change with same password as old"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.post(reverse('password_change'), {
|
||||
'old_password': 'testpass123',
|
||||
'new_password1': 'testpass123',
|
||||
'new_password2': 'testpass123'
|
||||
})
|
||||
# Django's default validators don't prevent same password, so it should succeed
|
||||
self.assertEqual(response.status_code, 302) # Redirect after success
|
||||
self.assertRedirects(response, '/') # Should redirect to main page
|
||||
|
||||
def test_password_change_cancel_button(self):
|
||||
"""Test password change cancel button"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.get(reverse('password_change'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Abbrechen')
|
||||
# The cancel button should link to main page
|
||||
self.assertContains(response, 'href="/"')
|
||||
|
||||
def test_user_menu_display_for_authenticated_user(self):
|
||||
"""Test that user menu displays correctly for authenticated users"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'testuser') # Username in menu
|
||||
self.assertContains(response, 'Passwort ändern') # Password change link
|
||||
self.assertContains(response, 'Abmelden') # Logout link
|
||||
self.assertNotContains(response, 'Anmelden') # Should not show login link
|
||||
|
||||
def test_login_link_display_for_anonymous_user(self):
|
||||
"""Test that login link displays for anonymous users"""
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Anmelden') # Should show login link
|
||||
self.assertNotContains(response, 'testuser') # Should not show username
|
||||
self.assertNotContains(response, 'Passwort ändern') # Should not show password change
|
||||
self.assertNotContains(response, 'Abmelden') # Should not show logout
|
||||
|
||||
def test_staff_user_menu(self):
|
||||
"""Test that staff users see appropriate menu"""
|
||||
self.client.login(username='staffuser', password='staffpass123')
|
||||
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'staffuser') # Username in menu
|
||||
self.assertContains(response, 'Passwort ändern') # Password change link
|
||||
self.assertContains(response, 'Abmelden') # Logout link
|
||||
|
||||
def test_login_redirect_to_main_page(self):
|
||||
"""Test that successful login redirects to main page"""
|
||||
response = self.client.post(reverse('login'), {
|
||||
'username': 'testuser',
|
||||
'password': 'testpass123'
|
||||
}, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should end up on main page
|
||||
self.assertContains(response, 'Vorgaben Informatiksicherheit')
|
||||
|
||||
def test_logout_redirect_to_main_page(self):
|
||||
"""Test that logout redirects to main page"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.post(reverse('logout'), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should end up on main page
|
||||
self.assertContains(response, 'Vorgaben Informatiksicherheit')
|
||||
# Should show login link for anonymous users
|
||||
self.assertContains(response, 'Anmelden')
|
||||
|
||||
def test_password_change_redirect_to_main_page(self):
|
||||
"""Test that successful password change redirects to main page"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
response = self.client.post(reverse('password_change'), {
|
||||
'old_password': 'testpass123',
|
||||
'new_password1': 'newpass456',
|
||||
'new_password2': 'newpass456'
|
||||
}, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should end up on main page
|
||||
self.assertContains(response, 'Vorgaben Informatiksicherheit')
|
||||
|
||||
def test_csrf_token_present_in_forms(self):
|
||||
"""Test that CSRF tokens are present in authentication forms"""
|
||||
# Login form
|
||||
response = self.client.get(reverse('login'))
|
||||
self.assertContains(response, 'csrfmiddlewaretoken')
|
||||
|
||||
# Password change form
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
response = self.client.get(reverse('password_change'))
|
||||
self.assertContains(response, 'csrfmiddlewaretoken')
|
||||
|
||||
def test_login_with_next_parameter(self):
|
||||
"""Test login with next parameter for redirect"""
|
||||
response = self.client.post(reverse('login'), {
|
||||
'username': 'testuser',
|
||||
'password': 'testpass123',
|
||||
'next': '/dokumente/'
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
# Should redirect to the specified next page
|
||||
self.assertRedirects(response, '/dokumente/')
|
||||
@@ -4,7 +4,6 @@ from django.utils import timezone
|
||||
from datetime import date, timedelta
|
||||
from dokumente.models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Geltungsbereich, Dokumententyp, Thema
|
||||
from stichworte.models import Stichwort
|
||||
from unittest.mock import patch
|
||||
import re
|
||||
|
||||
|
||||
@@ -67,24 +66,24 @@ class SearchViewTest(TestCase):
|
||||
"""Test POST request with valid search term"""
|
||||
response = self.client.post('/search/', {'q': 'Test'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Suchresultate für Test')
|
||||
self.assertContains(response, 'Suchergebnisse')
|
||||
|
||||
def test_search_case_insensitive(self):
|
||||
"""Test that search is case insensitive"""
|
||||
# Search for lowercase
|
||||
response = self.client.post('/search/', {'q': 'test'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Suchresultate für test')
|
||||
self.assertContains(response, 'Suchergebnisse für "test"')
|
||||
|
||||
# Search for uppercase
|
||||
response = self.client.post('/search/', {'q': 'TEST'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Suchresultate für TEST')
|
||||
self.assertContains(response, 'Suchergebnisse für "TEST"')
|
||||
|
||||
# Search for mixed case
|
||||
response = self.client.post('/search/', {'q': 'TeSt'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Suchresultate für TeSt')
|
||||
self.assertContains(response, 'Suchergebnisse für "TeSt"')
|
||||
|
||||
def test_search_in_kurztext(self):
|
||||
"""Test search in Kurztext content"""
|
||||
@@ -114,7 +113,7 @@ class SearchViewTest(TestCase):
|
||||
"""Test search with no results"""
|
||||
response = self.client.post('/search/', {'q': 'NichtVorhanden'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Keine Resultate für "NichtVorhanden"')
|
||||
self.assertContains(response, 'Keine Ergebnisse gefunden')
|
||||
|
||||
def test_search_expired_vorgabe_not_included(self):
|
||||
"""Test that expired Vorgaben are not included in results"""
|
||||
@@ -160,8 +159,8 @@ class SearchViewTest(TestCase):
|
||||
"""Test that HTML tags are stripped from search input"""
|
||||
response = self.client.post('/search/', {'q': '<script>alert("xss")</script>Test'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should search for "alert('xss')Test" after HTML tag removal
|
||||
self.assertContains(response, 'Suchresultate für alert("xss")Test')
|
||||
# Should search for "alert("xss")Test" after HTML tag removal
|
||||
self.assertContains(response, 'Suchergebnisse für "alert')
|
||||
|
||||
def test_search_invalid_characters_validation(self):
|
||||
"""Test validation for invalid characters"""
|
||||
@@ -206,7 +205,7 @@ class SearchViewTest(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# The input should be preserved (escaped) in the form
|
||||
# Since HTML tags are stripped, we expect "Test" to be searched
|
||||
self.assertContains(response, 'Suchresultate für Test')
|
||||
self.assertContains(response, 'Suchergebnisse für "Test"')
|
||||
|
||||
def test_search_xss_prevention_in_results(self):
|
||||
"""Test that search terms are escaped in results to prevent XSS"""
|
||||
@@ -218,15 +217,14 @@ class SearchViewTest(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# The script tag should be escaped in the output
|
||||
# Note: This depends on how the template renders the content
|
||||
self.assertContains(response, 'Suchresultate für term')
|
||||
self.assertContains(response, 'Suchergebnisse für "term"')
|
||||
|
||||
@patch('pages.views.pprint.pp')
|
||||
def test_search_result_logging(self, mock_pprint):
|
||||
"""Test that search results are logged for debugging"""
|
||||
def test_search_result_structure(self):
|
||||
"""Test that search results have expected structure"""
|
||||
response = self.client.post('/search/', {'q': 'Test'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Verify that pprint.pp was called with the result
|
||||
mock_pprint.assert_called_once()
|
||||
# Verify the results page is rendered with correct structure
|
||||
self.assertContains(response, 'Suchergebnisse für "Test"')
|
||||
|
||||
def test_search_multiple_documents(self):
|
||||
"""Test search across multiple documents"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.5 on 2025-11-27 22:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('referenzen', '0002_alter_referenz_table_alter_referenzerklaerung_table'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='referenzerklaerung',
|
||||
options={'verbose_name': 'Erklärung', 'verbose_name_plural': 'Erklärungen'},
|
||||
),
|
||||
]
|
||||
17
referenzen/migrations/0004_alter_referenz_options.py
Normal file
17
referenzen/migrations/0004_alter_referenz_options.py
Normal 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'},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,34 +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.5
|
||||
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.5.0
|
||||
wcwidth==0.2.13
|
||||
sqlparse==0.5.5
|
||||
tomli==2.2.1
|
||||
urllib3==2.6.3
|
||||
wcwidth==0.2.14
|
||||
webencodings==0.5.1
|
||||
whitenoise==6.11.0
|
||||
|
||||
17
rollen/migrations/0002_alter_rolle_options.py
Normal file
17
rollen/migrations/0002_alter_rolle_options.py
Normal 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)'},
|
||||
),
|
||||
]
|
||||
@@ -9,9 +9,10 @@ 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)
|
||||
class Meta:
|
||||
verbose_name_plural="Rollenbeschreibung"
|
||||
verbose_name="Rollenbeschreibungs-Abschnitt"
|
||||
verbose_name="Rollenbeschreibungs-Abschnitt"
|
||||
|
||||
216
scripts/deploy-argocd-configmap.sh
Executable file
216
scripts/deploy-argocd-configmap.sh
Executable 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
311
scripts/deploy-argocd-secret.sh
Executable 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
45
scripts/deploy_secret.sh
Executable 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
45
scripts/full_deploy.sh
Executable 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
27
scripts/partial_deploy.sh
Executable 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"
|
||||
@@ -11,4 +11,93 @@
|
||||
margin-bottom: 1em;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Comment System Styles */
|
||||
.comment-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comment-btn .comment-count {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment-item .text-muted {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#commentModal .modal-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#commentsContainer {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.delete-comment-btn {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.delete-comment-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-comment-btn {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.delete-comment-btn:hover {
|
||||
opacity: 1;
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
/* Icon styling for emoji replacements */
|
||||
.emoji-icon {
|
||||
font-size: 1.1em;
|
||||
margin-right: 0.3em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.comment-item .d-flex {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.delete-comment-btn {
|
||||
margin-left: 0 !important;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.5 on 2025-11-27 22:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stichworte', '0002_stichworterklaerung_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='stichworterklaerung',
|
||||
options={'verbose_name': 'Erklärung', 'verbose_name_plural': 'Erklärungen'},
|
||||
),
|
||||
]
|
||||
17
stichworte/migrations/0004_alter_stichwort_options.py
Normal file
17
stichworte/migrations/0004_alter_stichwort_options.py
Normal 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'},
|
||||
),
|
||||
]
|
||||
@@ -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
28
templates/configmap.yaml
Normal 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
10
templates/secret.yaml
Normal 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: ""
|
||||
Reference in New Issue
Block a user