Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79be502aa1 | |||
| d0f54f0dca | |||
| 2b00aab1fa | |||
| 6d4ac073a5 | |||
| 73ab1a37d2 | |||
| 0eb1ffe9d9 | |||
| 0ea1294237 | |||
| a7ff2f1552 | |||
| f847faea3e | |||
| 4c58dceb02 | |||
| db80693c6a | |||
| d451d58fc4 | |||
| 726520c9de | |||
| fabac77877 | |||
| bb2832c273 | |||
| ed122995ee | |||
| 634feda9da | |||
| fb8be06e81 | |||
| 6ad1720461 | |||
| 0c27d7ac86 | |||
| b3fc470c88 | |||
| 541ca7172b | |||
| 001a9fae17 | |||
| b572704381 |
@@ -1,8 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
working_dir: /app/Web
|
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
|
||||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
|
||||||
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
|
||||||
build: null
|
build: null
|
||||||
ports:
|
ports:
|
||||||
- "10000:8000"
|
- "10000:8000"
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ on:
|
|||||||
options:
|
options:
|
||||||
- patch
|
- patch
|
||||||
- minor
|
- minor
|
||||||
|
- major
|
||||||
|
- development
|
||||||
|
push_dev:
|
||||||
|
description: "If true, push the :dev image to GHCR for development releases"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- "true"
|
||||||
|
- "false"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -38,57 +48,46 @@ jobs:
|
|||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
||||||
|
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
|
||||||
run: |
|
run: |
|
||||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||||
TAG="$REF_NAME"
|
TAG="$REF_NAME"
|
||||||
else
|
else
|
||||||
TAG="$(python3 - <<'PY'
|
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
||||||
import json
|
latest_tag="v3.0.0"
|
||||||
import os
|
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
|
||||||
import re
|
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||||
import urllib.request
|
if [ -n "$tag_name" ]; then
|
||||||
|
latest_tag="$tag_name"
|
||||||
repo = os.environ["REPO"]
|
fi
|
||||||
token = os.environ.get("GH_TOKEN", "")
|
|
||||||
bump = os.environ.get("BUMP_TYPE", "patch").strip().lower()
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"https://api.github.com/repos/{repo}/releases/latest",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Accept": "application/vnd.github+json",
|
|
||||||
"User-Agent": "legendary-octo-garbanzo-release-workflow",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
latest_tag = "v3.0.0"
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
latest_tag = (data.get("tag_name") or latest_tag).strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", latest_tag)
|
|
||||||
if not m:
|
|
||||||
major, minor, patch = 3, 0, 0
|
|
||||||
else:
|
|
||||||
major, minor, patch = map(int, m.groups())
|
|
||||||
|
|
||||||
# Keep major fixed from latest release; only bump minor/patch.
|
|
||||||
if bump == "minor":
|
|
||||||
minor += 1
|
|
||||||
patch = 0
|
|
||||||
else:
|
|
||||||
patch += 1
|
|
||||||
|
|
||||||
print(f"v{major}.{minor}.{patch}")
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
if [[ "$latest_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||||
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
|
major=${BASH_REMATCH[1]}
|
||||||
|
minor=${BASH_REMATCH[2]}
|
||||||
|
patch=${BASH_REMATCH[3]}
|
||||||
|
else
|
||||||
|
major=3; minor=0; patch=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bump strategy: major / minor / patch
|
||||||
|
if [ "${BUMP_TYPE:-}" = "major" ]; then
|
||||||
|
major=$((major + 1)); minor=0; patch=0
|
||||||
|
elif [ "${BUMP_TYPE:-}" = "minor" ]; then
|
||||||
|
minor=$((minor + 1)); patch=0
|
||||||
|
else
|
||||||
|
patch=$((patch + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
||||||
|
TAG="v${major}.${minor}.${patch}-dev"
|
||||||
|
else
|
||||||
|
TAG="v${major}.${minor}.${patch}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
|
||||||
|
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z or vX.Y.Z-dev)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -105,21 +104,40 @@ jobs:
|
|||||||
LATEST_MAJOR="3"
|
LATEST_MAJOR="3"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
|
# If not explicitly bumping major, disallow changing major version
|
||||||
|
if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
|
||||||
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
|
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
while git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; do
|
# Ensure tag uniqueness: if tag exists append numeric suffix
|
||||||
BASE="${TAG%.*}"
|
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||||
PATCH="${TAG##*.}"
|
i=1
|
||||||
PATCH="$((PATCH + 1))"
|
base="$TAG"
|
||||||
TAG="$BASE.$PATCH"
|
while git rev-parse -q --verify "refs/tags/${base}.${i}" >/dev/null; do
|
||||||
|
i="$((i + 1))"
|
||||||
done
|
done
|
||||||
|
TAG="${base}.${i}"
|
||||||
|
fi
|
||||||
|
|
||||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
||||||
|
echo "is_development=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "is_development=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
|
||||||
|
echo "push_dev=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "push_dev=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update .release-version file
|
||||||
|
run: |
|
||||||
|
echo "${{ steps.meta.outputs.tag }}" > .release-version
|
||||||
|
cat .release-version
|
||||||
|
|
||||||
- name: Create and push tag for manual releases
|
- name: Create and push tag for manual releases
|
||||||
if: github.event_name == 'workflow_dispatch'
|
if: github.event_name == 'workflow_dispatch'
|
||||||
@@ -127,8 +145,11 @@ jobs:
|
|||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .release-version
|
||||||
|
git commit -m "chore: bump version to $TAG" || true
|
||||||
git tag "$TAG"
|
git tag "$TAG"
|
||||||
git push origin "$TAG"
|
git push origin "$TAG"
|
||||||
|
git push origin HEAD:${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -141,6 +162,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push release image
|
- name: Build and push release image
|
||||||
|
if: steps.meta.outputs.is_development != 'true'
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -150,10 +172,50 @@ jobs:
|
|||||||
${{ steps.meta.outputs.image }}
|
${{ steps.meta.outputs.image }}
|
||||||
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||||
|
|
||||||
|
- name: Build and push development image (:dev)
|
||||||
|
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
|
||||||
|
|
||||||
- name: Build local image tar for offline deploy
|
- name: Build local image tar for offline deploy
|
||||||
run: |
|
run: |
|
||||||
docker pull "${{ steps.meta.outputs.image }}"
|
set -euo pipefail
|
||||||
docker save "${{ steps.meta.outputs.image }}" | gzip > "inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz"
|
IMG="${{ steps.meta.outputs.image }}"
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
|
||||||
|
if docker image inspect "$IMG" >/dev/null 2>&1; then
|
||||||
|
echo "Using local image $IMG"
|
||||||
|
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Local image $IMG not found, trying to pull"
|
||||||
|
if docker pull "$IMG" >/dev/null 2>&1; then
|
||||||
|
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Pull failed, attempting local docker build as fallback"
|
||||||
|
docker build -t "$IMG" .
|
||||||
|
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||||
|
|
||||||
|
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
|
||||||
|
|
||||||
|
- name: Commit .release-version for tag pushes
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
if ! git diff --quiet .release-version; then
|
||||||
|
git add .release-version
|
||||||
|
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
|
||||||
|
git push origin HEAD:${{ github.ref_name }}
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create release-only docker bundle
|
- name: Create release-only docker bundle
|
||||||
run: |
|
run: |
|
||||||
@@ -224,29 +286,43 @@ jobs:
|
|||||||
redis_data:
|
redis_data:
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cp start.sh release-bundle/start.sh
|
# Copy runtime scripts and config if present
|
||||||
cp stop.sh release-bundle/stop.sh
|
for f in start.sh stop.sh restart.sh backup.sh config.json update.sh; do
|
||||||
cp restart.sh release-bundle/restart.sh
|
if [ -f "$f" ]; then
|
||||||
cp backup.sh release-bundle/backup.sh
|
cp "$f" "release-bundle/$(basename "$f")"
|
||||||
cp config.json release-bundle/config.json
|
fi
|
||||||
cp update.sh release-bundle/update.sh
|
done
|
||||||
|
|
||||||
# Multitenant scripts & docs
|
# Multitenant scripts & docs (optional)
|
||||||
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
|
for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do
|
||||||
cp manage-tenant.sh release-bundle/manage-tenant.sh
|
if [ -f "$f" ]; then
|
||||||
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
|
cp "$f" "release-bundle/$(basename "$f")"
|
||||||
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
|
fi
|
||||||
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
|
done
|
||||||
|
|
||||||
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
|
cat > release-bundle/DEVELOPMENT.md <<EOF
|
||||||
|
This is a development prerelease bundle for tag: ${{ steps.meta.outputs.tag }}
|
||||||
|
|
||||||
|
This prerelease is intentionally separate from normal releases and will not be used by default.
|
||||||
|
|
||||||
|
To install this prerelease on a target host run:
|
||||||
|
|
||||||
|
./update.sh ${{ steps.meta.outputs.tag }}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Make any shipped scripts executable
|
||||||
|
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||||
|
|
||||||
- name: Create or update GitHub Release
|
- name: Create or update GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.meta.outputs.tag }}
|
tag_name: ${{ steps.meta.outputs.tag }}
|
||||||
|
prerelease: ${{ steps.meta.outputs.is_development }}
|
||||||
files: |
|
files: |
|
||||||
inventarsystem-docker-bundle.tar.gz
|
inventarsystem-docker-bundle.tar.gz
|
||||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||||
fail_on_unmatched_files: true
|
inventarsystem-image-dev.tar.gz
|
||||||
|
fail_on_unmatched_files: false
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.7.42
|
v0.7.71
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
Release-Optionen
|
||||||
|
=================
|
||||||
|
|
||||||
|
Diese Datei beschreibt die Eingabeoptionen des CI-Workflows `.github/workflows/release-docker.yml`.
|
||||||
|
|
||||||
|
Inputs (workflow_dispatch)
|
||||||
|
- `bump` (choice)
|
||||||
|
- `patch` (Standard): Erhöht nur die Patch-Version (vX.Y.Z -> vX.Y.Z+1).
|
||||||
|
- `minor`: Erhöht die Minor-Version und setzt Patch auf 0 (vX.Y.Z -> vX.Y+1.0).
|
||||||
|
- `major`: Erhöht die Major-Version und setzt Minor/Patch auf 0 (vX.Y.Z -> vX+1.0.0).
|
||||||
|
- `development`: Erzeugt einen Development-Release mit Suffix `-dev` (z. B. `v3.1.4-dev`).
|
||||||
|
|
||||||
|
- `push_dev` (choice, optional)
|
||||||
|
- `false` (Standard): Bei `bump=development` wird das `:dev`-Image NICHT automatisch an GHCR gepusht.
|
||||||
|
- `true`: Bei `bump=development` wird zusätzlich das Image `ghcr.io/aiirondev/legendary-octo-garbanzo:dev` gepusht.
|
||||||
|
|
||||||
|
Verhalten/Anmerkungen
|
||||||
|
- Development releases werden als GitHub Release erzeugt und als `prerelease` markiert, damit sie nicht automatisch von normalen Update‑Flows genutzt werden.
|
||||||
|
- Es gibt pro Release genau einen Release‑Eintrag (für Dev‑Releases mit `-dev` Suffix). Es wird kein separates `inventarsystem-image-dev.tar.gz` mehr erzeugt; das Update/Deployment erfolgt über den Release‑Tag / Image‑Tag.
|
||||||
|
- `update.sh` unterstützt weiterhin `dev`/`development`-Modus und akzeptiert nun auch explizite Release‑Tags wie `v3.1.4-dev`.
|
||||||
|
|
||||||
|
Beispiele
|
||||||
|
- Patch-Release (manuell):
|
||||||
|
- GitHub UI: Run workflow → `bump=patch`
|
||||||
|
- CLI mit `gh`:
|
||||||
|
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=patch
|
||||||
|
|
||||||
|
- Development prerelease (ohne Push des :dev Images):
|
||||||
|
- GitHub UI: Run workflow → `bump=development` (leave `push_dev=false`)
|
||||||
|
- Ergebnis: Release `vX.Y.Z-dev` als prerelease, Image wird nicht automatisch als `:dev` gepusht.
|
||||||
|
|
||||||
|
- Development prerelease + push des :dev Images:
|
||||||
|
- GitHub UI: Run workflow → `bump=development`, `push_dev=true`
|
||||||
|
- CLI Beispiel:
|
||||||
|
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=development --field push_dev=true
|
||||||
|
|
||||||
|
Empfehlung
|
||||||
|
- Verwende `bump=development` für experimentelle/early releases; Nutzer müssen explizit `./update.sh vX.Y.Z-dev` ausführen, um auf diese Version zu upgraden.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Web package initialization
|
||||||
+66
-24
@@ -28,13 +28,26 @@ Features:
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort
|
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
from werkzeug.routing import BuildError
|
||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
import user as us
|
import os
|
||||||
import items as it
|
import sys
|
||||||
import ausleihung as au
|
|
||||||
import audit_log as al
|
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
|
||||||
|
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_PROJECT_ROOT = os.path.dirname(_CURRENT_DIR)
|
||||||
|
if _PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _PROJECT_ROOT)
|
||||||
|
if _CURRENT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, _CURRENT_DIR)
|
||||||
|
|
||||||
|
import Web.modules.database.user as us
|
||||||
|
import Web.modules.database.items as it
|
||||||
|
import Web.modules.database.ausleihung as au
|
||||||
|
import Web.modules.log.audit_log as al
|
||||||
import push_notifications as pn
|
import push_notifications as pn
|
||||||
import pdf_export
|
import Web.modules.inventarsystem.pdf_export as pdf_export
|
||||||
import datetime
|
import datetime
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
@@ -42,7 +55,6 @@ from urllib.parse import urlparse, urlunparse
|
|||||||
import requests
|
import requests
|
||||||
import csv
|
import csv
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
@@ -63,13 +75,12 @@ except Exception:
|
|||||||
# import qrcode
|
# import qrcode
|
||||||
# from qrcode.constants import ERROR_CORRECT_L
|
# from qrcode.constants import ERROR_CORRECT_L
|
||||||
import threading
|
import threading
|
||||||
import sys
|
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import subprocess
|
import subprocess
|
||||||
from data_protection import (
|
from Web.modules.inventarsystem.data_protection import (
|
||||||
decrypt_document_fields,
|
decrypt_document_fields,
|
||||||
encrypt_document_fields,
|
encrypt_document_fields,
|
||||||
encrypt_soft_deleted_media_pack,
|
encrypt_soft_deleted_media_pack,
|
||||||
@@ -77,8 +88,8 @@ from data_protection import (
|
|||||||
|
|
||||||
# Set base directory and centralized settings
|
# Set base directory and centralized settings
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
from tenant import get_tenant_context
|
from tenant import get_tenant_context
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +120,7 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
|||||||
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
|
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
|
||||||
|
|
||||||
if not os.path.exists(app.config['UPLOAD_FOLDER']):
|
if not os.path.exists(app.config['UPLOAD_FOLDER']):
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'])
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
# QR Code directory creation deactivated
|
# QR Code directory creation deactivated
|
||||||
# if not os.path.exists(app.config['QR_CODE_FOLDER']):
|
# if not os.path.exists(app.config['QR_CODE_FOLDER']):
|
||||||
# os.makedirs(app.config['QR_CODE_FOLDER'])
|
# os.makedirs(app.config['QR_CODE_FOLDER'])
|
||||||
@@ -373,7 +384,7 @@ def _enforce_module_access():
|
|||||||
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
|
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
|
||||||
return redirect(url_for('home'))
|
return redirect(url_for('home'))
|
||||||
elif name != 'library' and cfg.MODULES.is_enabled('library'):
|
elif name != 'library' and cfg.MODULES.is_enabled('library'):
|
||||||
return redirect(url_for('library_view'))
|
return redirect('/library')
|
||||||
|
|
||||||
return redirect(url_for('my_borrowed_items'))
|
return redirect(url_for('my_borrowed_items'))
|
||||||
|
|
||||||
@@ -433,6 +444,41 @@ def handle_not_found(e):
|
|||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(BuildError)
|
||||||
|
def handle_build_error(e):
|
||||||
|
"""Handle url_for endpoint mismatches without crashing the whole request."""
|
||||||
|
app.logger.error(f"BuildError while resolving endpoint: {e}", exc_info=True)
|
||||||
|
if request.is_json or request.path.startswith('/api/'):
|
||||||
|
return jsonify({'error': 'Route resolution failed', 'status': 500}), 500
|
||||||
|
|
||||||
|
# Known fallback for legacy library endpoint naming issues.
|
||||||
|
if 'library_view' in str(e):
|
||||||
|
return redirect('/library')
|
||||||
|
|
||||||
|
if 'username' in session:
|
||||||
|
return redirect(url_for('home'))
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_unexpected_exception(e):
|
||||||
|
"""Last-resort handler to avoid raw 500 pages for unhandled exceptions."""
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
return e
|
||||||
|
|
||||||
|
app.logger.error('Unhandled exception', exc_info=True)
|
||||||
|
if request.is_json or request.path.startswith('/api/'):
|
||||||
|
return jsonify({'error': 'Internal server error', 'status': 500}), 500
|
||||||
|
|
||||||
|
if 'username' in session:
|
||||||
|
try:
|
||||||
|
return render_template('error.html', error_code=500, error_message='Interner Serverfehler.'), 500
|
||||||
|
except Exception:
|
||||||
|
return 'Internal Server Error', 500
|
||||||
|
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
|
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
|
||||||
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
|
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
|
||||||
return jsonify({'error': message}), 400
|
return jsonify({'error': message}), 400
|
||||||
@@ -2369,11 +2415,7 @@ def preview_file(filename):
|
|||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
|
|
||||||
# Check production path first
|
|
||||||
prod_path = "/var/Inventarsystem/Web/previews"
|
|
||||||
dev_path = app.config['PREVIEW_FOLDER']
|
dev_path = app.config['PREVIEW_FOLDER']
|
||||||
if os.path.exists(os.path.join(prod_path, filename)):
|
|
||||||
return send_from_directory(prod_path, filename)
|
|
||||||
if os.path.exists(os.path.join(dev_path, filename)):
|
if os.path.exists(os.path.join(dev_path, filename)):
|
||||||
return send_from_directory(dev_path, filename)
|
return send_from_directory(dev_path, filename)
|
||||||
|
|
||||||
@@ -2640,7 +2682,7 @@ def home():
|
|||||||
|
|
||||||
if not cfg.MODULES.is_enabled('inventory'):
|
if not cfg.MODULES.is_enabled('inventory'):
|
||||||
if cfg.MODULES.is_enabled('library'):
|
if cfg.MODULES.is_enabled('library'):
|
||||||
return redirect(url_for('library_view'))
|
return redirect('/library')
|
||||||
else:
|
else:
|
||||||
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
|
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
|
||||||
|
|
||||||
@@ -2716,17 +2758,16 @@ def tutorial_page():
|
|||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/library')
|
@app.route('/library/export', defaults={'scope': 'all'})
|
||||||
|
|
||||||
@app.route('/library/export/<scope>')
|
@app.route('/library/export/<scope>')
|
||||||
def library_export_excel(scope):
|
def library_export_excel(scope='all'):
|
||||||
if 'username' not in session:
|
if 'username' not in session:
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
username = session['username']
|
username = session['username']
|
||||||
is_admin_user = us.check_admin(username)
|
is_admin_user = us.check_admin(username)
|
||||||
|
|
||||||
import excel_export
|
import Web.modules.inventarsystem.excel_export as excel_export
|
||||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
items_collection = db['items']
|
items_collection = db['items']
|
||||||
@@ -2739,7 +2780,7 @@ def library_export_excel(scope):
|
|||||||
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
|
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
|
||||||
elif scope == 'all_borrowed':
|
elif scope == 'all_borrowed':
|
||||||
if not is_admin_user:
|
if not is_admin_user:
|
||||||
return redirect(url_for('library_view'))
|
return redirect('/library')
|
||||||
query['Verfuegbar'] = False
|
query['Verfuegbar'] = False
|
||||||
filename = "Bibliothek_Alle_Ausleihen.xlsx"
|
filename = "Bibliothek_Alle_Ausleihen.xlsx"
|
||||||
elif scope == 'schulbuecher':
|
elif scope == 'schulbuecher':
|
||||||
@@ -2753,13 +2794,14 @@ def library_export_excel(scope):
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
excel_file = excel_export.generate_library_excel(items)
|
excel_file = excel_export.generate_library_excel(items)
|
||||||
return flask.send_file(
|
return send_file(
|
||||||
excel_file,
|
excel_file,
|
||||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=filename
|
download_name=filename
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route('/library')
|
||||||
def library_view():
|
def library_view():
|
||||||
"""
|
"""
|
||||||
Dedicated page for viewing library items (books, CDs, etc.).
|
Dedicated page for viewing library items (books, CDs, etc.).
|
||||||
@@ -10105,7 +10147,7 @@ def reset_item(id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Import the ausleihung module
|
# Import the ausleihung module
|
||||||
import ausleihung as au
|
import Web.modules.database.ausleihung as au
|
||||||
|
|
||||||
result = au.reset_item_completely(id)
|
result = au.reset_item_completely(id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Web.modules package initialization
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Web.modules.bibliothek package initialization
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Log initialization
|
||||||
@@ -32,8 +32,8 @@ from datetime import timezone
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
# Add this helper function after imports
|
# Add this helper function after imports
|
||||||
def ensure_timezone_aware(dt):
|
def ensure_timezone_aware(dt):
|
||||||
@@ -103,7 +103,7 @@ def get_current_status(ausleihung, log_changes=False, user=None):
|
|||||||
if log_changes and new_status != original_status and '_id' in ausleihung:
|
if log_changes and new_status != original_status and '_id' in ausleihung:
|
||||||
try:
|
try:
|
||||||
# Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden
|
# Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden
|
||||||
import ausleihung_log
|
import Web.modules.log.ausleihung_log as ausleihung_log
|
||||||
ausleihung_log.log_status_change(
|
ausleihung_log.log_status_change(
|
||||||
str(ausleihung['_id']),
|
str(ausleihung['_id']),
|
||||||
original_status,
|
original_status,
|
||||||
@@ -28,8 +28,8 @@ Collection Structure:
|
|||||||
'''
|
'''
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import datetime
|
import datetime
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
|
|
||||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
||||||
@@ -134,8 +134,14 @@ def _get_int_env(name, default):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return int(default)
|
return int(default)
|
||||||
|
|
||||||
|
def get_version():
|
||||||
|
with open(os.path.join(BASE_DIR, '..', '..', '..', '.docker-build.env'), 'r') as f:
|
||||||
|
for l in f:
|
||||||
|
if l.startswith('INVENTAR_APP_IMAGE='):
|
||||||
|
return l.split(':', 1)[1].strip()
|
||||||
|
|
||||||
# Expose settings
|
# Expose settings
|
||||||
APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version'])
|
APP_VERSION = get_version()
|
||||||
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
|
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
|
||||||
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
|
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
|
||||||
HOST = _get(_conf, ['host'], DEFAULTS['host'])
|
HOST = _get(_conf, ['host'], DEFAULTS['host'])
|
||||||
@@ -205,7 +211,7 @@ class _TenantAwareBool:
|
|||||||
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
|
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
|
||||||
|
|
||||||
|
|
||||||
from module_registry import registry as MODULES
|
from Web.modules.module_registry import registry as MODULES
|
||||||
|
|
||||||
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
|
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
|
||||||
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
import Web.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
|
|
||||||
def get_filter_names():
|
def get_filter_names():
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
@@ -17,8 +17,8 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
logger = logging.getLogger('app')
|
logger = logging.getLogger('app')
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Web.modules.emailservice package initialization
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Inventar System Funktionen
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
|
|
||||||
_ENC_PREFIX = "enc::"
|
_ENC_PREFIX = "enc::"
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||||
'''
|
'''
|
||||||
import user
|
import Web.modules.database.user as user
|
||||||
import sys
|
import sys
|
||||||
import getpass
|
import getpass
|
||||||
import re
|
import re
|
||||||
@@ -18,7 +18,7 @@ from reportlab.pdfgen import canvas
|
|||||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
|
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
|
||||||
from reportlab.pdfbase import pdfmetrics
|
from reportlab.pdfbase import pdfmetrics
|
||||||
from reportlab.pdfbase.ttfonts import TTFont
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
|
|
||||||
|
|
||||||
__version__ = cfg.APP_VERSION
|
__version__ = cfg.APP_VERSION
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Web.modules.log package initialization
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
print("hello")
|
||||||
@@ -11,8 +11,8 @@ import requests
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
|
||||||
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
|
<a class="btn btn-secondary" href="{{ url_for('library_admin') }}">Bibliothek öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+29
-3
@@ -12,8 +12,8 @@ from functools import wraps
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -113,6 +113,28 @@ def _normalize_db_name(db_name):
|
|||||||
return db_name
|
return db_name
|
||||||
|
|
||||||
|
|
||||||
|
def _module_name_candidates(module_name):
|
||||||
|
normalized = str(module_name or '').strip().lower().replace('-', '_')
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = [normalized]
|
||||||
|
alias_groups = {
|
||||||
|
'inventory': {'inventory', 'inventar'},
|
||||||
|
'library': {'library', 'bib', 'bibliothek'},
|
||||||
|
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
|
||||||
|
}
|
||||||
|
|
||||||
|
for canonical_name, aliases in alias_groups.items():
|
||||||
|
if normalized in aliases:
|
||||||
|
for alias in (canonical_name, *sorted(aliases)):
|
||||||
|
if alias not in candidates:
|
||||||
|
candidates.append(alias)
|
||||||
|
break
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def _tenant_db_aliases(tenant_id):
|
def _tenant_db_aliases(tenant_id):
|
||||||
aliases = []
|
aliases = []
|
||||||
env_map = _parse_tenant_db_map()
|
env_map = _parse_tenant_db_map()
|
||||||
@@ -153,9 +175,13 @@ def _resolve_db_alias(tenant_id, db_name):
|
|||||||
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
|
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
|
||||||
"""Resolve whether a feature module is enabled for the current tenant."""
|
"""Resolve whether a feature module is enabled for the current tenant."""
|
||||||
config = get_tenant_config(tenant_id)
|
config = get_tenant_config(tenant_id)
|
||||||
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
|
for candidate_name in _module_name_candidates(module_name):
|
||||||
|
enabled = _get_nested_value(config, ['modules', candidate_name, 'enabled'], None)
|
||||||
|
if enabled is not None:
|
||||||
return bool(enabled)
|
return bool(enabled)
|
||||||
|
|
||||||
|
return bool(default)
|
||||||
|
|
||||||
|
|
||||||
class TenantContext:
|
class TenantContext:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -144,7 +144,13 @@ services:
|
|||||||
|
|
||||||
# Health check for load balancer
|
# Health check for load balancer
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read(); print('OK')"
|
||||||
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
+48
-5
@@ -9,7 +9,47 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CONFIG_FILE="$PWD/config.json"
|
# Resolve script directory so config paths are deterministic even when called via sudo
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||||
|
|
||||||
|
ensure_runtime_config_json() {
|
||||||
|
local config_path backup_path
|
||||||
|
config_path="$CONFIG_FILE"
|
||||||
|
|
||||||
|
if [ -d "$config_path" ]; then
|
||||||
|
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
|
||||||
|
mv "$config_path" "$backup_path"
|
||||||
|
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$config_path" ]; then
|
||||||
|
cat > "$config_path" <<'EOF'
|
||||||
|
{
|
||||||
|
"ver": "2.6.5",
|
||||||
|
"dbg": false,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8000,
|
||||||
|
"mongodb": {
|
||||||
|
"host": "mongodb",
|
||||||
|
"port": 27017,
|
||||||
|
"db": "Inventarsystem"
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"library": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"student_cards": {
|
||||||
|
"enabled": false,
|
||||||
|
"default_borrow_days": 14,
|
||||||
|
"max_borrow_days": 365
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "Created default runtime config at $config_path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
show_help() {
|
show_help() {
|
||||||
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
||||||
@@ -177,6 +217,8 @@ restart_app_container() {
|
|||||||
local env_file="$PWD/.docker-build.env"
|
local env_file="$PWD/.docker-build.env"
|
||||||
local compose_args=()
|
local compose_args=()
|
||||||
|
|
||||||
|
ensure_runtime_config_json
|
||||||
|
|
||||||
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
|
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
|
||||||
if [ -n "$HOST_WORKDIR" ]; then
|
if [ -n "$HOST_WORKDIR" ]; then
|
||||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
||||||
@@ -333,7 +375,7 @@ case "$COMMAND" in
|
|||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
import sys, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
|
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
|
||||||
tenant_id = sys.argv[1].lower()
|
tenant_id = sys.argv[1].lower()
|
||||||
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
|
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
|
||||||
db_name = f'inventar_{sanitized}'
|
db_name = f'inventar_{sanitized}'
|
||||||
@@ -401,7 +443,7 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
|||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
import sys; sys.path.insert(0, '/app/Web'); from tenant import TenantContext; import settings; from pymongo import MongoClient
|
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
|
||||||
ctx = TenantContext()
|
ctx = TenantContext()
|
||||||
db_name = ctx._get_db_name(sys.argv[1])
|
db_name = ctx._get_db_name(sys.argv[1])
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
@@ -448,7 +490,7 @@ print(f'Database for tenant {sys.argv[1]} dropped.')
|
|||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||||
db.sessions.drop() # Force sign-out / session clear
|
db.sessions.drop() # Force sign-out / session clear
|
||||||
@@ -495,8 +537,9 @@ PY
|
|||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
|
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
|
||||||
import sys
|
import sys
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
sys.path.insert(0, '/app/Web')
|
sys.path.insert(0, '/app/Web')
|
||||||
import settings
|
from Web.modules.database import settings
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
prefix = 'inventar_'
|
prefix = 'inventar_'
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Wrapper to run tenant management fully containerized via docker-compose
|
|
||||||
PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$(basename "$PWD" | tr '[:upper:]' '[:lower:]')}
|
|
||||||
# Pass the absolute working directory to the container so docker compose can resolve paths correctly
|
|
||||||
docker compose -f docker-compose-multitenant.yml --profile tools run --rm -e COMPOSE_PROJECT_NAME="$PROJECT_NAME" -e HOST_WORKDIR="$PWD" tenant-manager "$@"
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,588 +0,0 @@
|
|||||||
"""
|
|
||||||
Test Suite for Ausleihung (Borrowing) System
|
|
||||||
Tests all core functionality of the borrowing/lending module
|
|
||||||
|
|
||||||
Run with: pytest test_ausleihung.py -v
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import datetime
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add Web directory to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'Web'))
|
|
||||||
|
|
||||||
import ausleihung
|
|
||||||
import settings as cfg
|
|
||||||
from settings import MongoClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
|
||||||
def db_client():
|
|
||||||
"""Create MongoDB connection for tests"""
|
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
||||||
yield client
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
|
||||||
def test_db(db_client):
|
|
||||||
"""Get test database"""
|
|
||||||
return db_client[cfg.MONGODB_DB]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def cleanup_test_data(test_db):
|
|
||||||
"""Clean up test data before and after each test"""
|
|
||||||
yield
|
|
||||||
# Clean up after test
|
|
||||||
test_db['ausleihungen'].delete_many({})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_ausleihung_data():
|
|
||||||
"""Fixture with sample borrowing data"""
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
return {
|
|
||||||
'item_id': str(ObjectId()),
|
|
||||||
'user': 'test_user',
|
|
||||||
'start_date': now,
|
|
||||||
'end_date': now + datetime.timedelta(days=1),
|
|
||||||
'notes': 'Test borrowing',
|
|
||||||
'period': None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Status Determination Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestGetCurrentStatus:
|
|
||||||
"""Test status determination based on dates"""
|
|
||||||
|
|
||||||
def test_planned_status_future_date(self):
|
|
||||||
"""Test that future borrowing is marked as 'planned'"""
|
|
||||||
future_time = datetime.datetime.now() + datetime.timedelta(days=1)
|
|
||||||
ausleihung_doc = {
|
|
||||||
'Status': 'planned',
|
|
||||||
'Start': future_time,
|
|
||||||
'End': future_time + datetime.timedelta(hours=1)
|
|
||||||
}
|
|
||||||
status = ausleihung.get_current_status(ausleihung_doc)
|
|
||||||
assert status == 'planned'
|
|
||||||
|
|
||||||
def test_active_status_during_borrowing(self):
|
|
||||||
"""Test that current borrowing is marked as 'active'"""
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
start = now - datetime.timedelta(hours=1)
|
|
||||||
end = now + datetime.timedelta(hours=1)
|
|
||||||
ausleihung_doc = {
|
|
||||||
'Status': 'active',
|
|
||||||
'Start': start,
|
|
||||||
'End': end
|
|
||||||
}
|
|
||||||
status = ausleihung.get_current_status(ausleihung_doc)
|
|
||||||
assert status == 'active'
|
|
||||||
|
|
||||||
def test_completed_status_after_end_time(self):
|
|
||||||
"""Test that past borrowing is marked as 'completed'"""
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
start = now - datetime.timedelta(days=2)
|
|
||||||
end = now - datetime.timedelta(hours=1)
|
|
||||||
ausleihung_doc = {
|
|
||||||
'Status': 'active',
|
|
||||||
'Start': start,
|
|
||||||
'End': end
|
|
||||||
}
|
|
||||||
status = ausleihung.get_current_status(ausleihung_doc)
|
|
||||||
assert status == 'completed'
|
|
||||||
|
|
||||||
def test_cancelled_status_remains_cancelled(self):
|
|
||||||
"""Test that cancelled status is never changed"""
|
|
||||||
future_time = datetime.datetime.now() + datetime.timedelta(days=1)
|
|
||||||
ausleihung_doc = {
|
|
||||||
'Status': 'cancelled',
|
|
||||||
'Start': future_time,
|
|
||||||
'End': future_time + datetime.timedelta(hours=1)
|
|
||||||
}
|
|
||||||
status = ausleihung.get_current_status(ausleihung_doc)
|
|
||||||
assert status == 'cancelled'
|
|
||||||
|
|
||||||
def test_active_with_no_end_time(self):
|
|
||||||
"""Test that borrowing without end time stays active if started"""
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
start = now - datetime.timedelta(hours=1)
|
|
||||||
ausleihung_doc = {
|
|
||||||
'Status': 'active',
|
|
||||||
'Start': start,
|
|
||||||
'End': None
|
|
||||||
}
|
|
||||||
status = ausleihung.get_current_status(ausleihung_doc)
|
|
||||||
assert status == 'active'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Create and Update Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestCreateAusleihung:
|
|
||||||
"""Test creating new borrowings"""
|
|
||||||
|
|
||||||
def test_create_active_ausleihung(self, test_db):
|
|
||||||
"""Test creating an immediately active borrowing"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
start = datetime.datetime.now()
|
|
||||||
end = start + datetime.timedelta(hours=2)
|
|
||||||
|
|
||||||
result = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=start,
|
|
||||||
end_date=end,
|
|
||||||
notes='Test active',
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
# Verify in database
|
|
||||||
ausleihung_col = test_db['ausleihungen']
|
|
||||||
stored = ausleihung_col.find_one({'_id': result})
|
|
||||||
assert stored is not None
|
|
||||||
assert stored['Item'] == item_id # Correct field name
|
|
||||||
assert stored['User'] == user
|
|
||||||
assert stored['Status'] == 'active'
|
|
||||||
|
|
||||||
def test_create_planned_ausleihung(self, test_db):
|
|
||||||
"""Test creating a future/planned borrowing"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
future_start = datetime.datetime.now() + datetime.timedelta(days=1)
|
|
||||||
future_end = future_start + datetime.timedelta(hours=2)
|
|
||||||
|
|
||||||
result = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=future_start,
|
|
||||||
end_date=future_end,
|
|
||||||
notes='Test planned',
|
|
||||||
status='planned'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
ausleihung_col = test_db['ausleihungen']
|
|
||||||
stored = ausleihung_col.find_one({'_id': result})
|
|
||||||
assert stored['Status'] == 'planned'
|
|
||||||
# Check approximately equal (within 1 second for datetime precision)
|
|
||||||
assert abs((stored['Start'] - future_start).total_seconds()) < 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateAusleihung:
|
|
||||||
"""Test updating existing borrowings"""
|
|
||||||
|
|
||||||
def test_update_ausleihung_dates(self, test_db, sample_ausleihung_data):
|
|
||||||
"""Test updating borrowing dates"""
|
|
||||||
# Create initial borrowing
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(**sample_ausleihung_data)
|
|
||||||
assert ausleihung_id is not None
|
|
||||||
|
|
||||||
# Update dates
|
|
||||||
new_start = datetime.datetime.now() + datetime.timedelta(days=2)
|
|
||||||
new_end = new_start + datetime.timedelta(hours=1)
|
|
||||||
|
|
||||||
ausleihung.update_ausleihung(
|
|
||||||
id=ausleihung_id,
|
|
||||||
start=new_start,
|
|
||||||
end=new_end
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify update (within 1 second tolerance for datetime precision)
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert abs((stored['Start'] - new_start).total_seconds()) < 1
|
|
||||||
assert abs((stored['End'] - new_end).total_seconds()) < 1
|
|
||||||
|
|
||||||
def test_update_ausleihung_status(self, test_db, sample_ausleihung_data):
|
|
||||||
"""Test updating borrowing status"""
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(**sample_ausleihung_data)
|
|
||||||
|
|
||||||
ausleihung.update_ausleihung(id=ausleihung_id, status='completed')
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored['Status'] == 'completed'
|
|
||||||
|
|
||||||
def test_update_ausleihung_notes(self, test_db, sample_ausleihung_data):
|
|
||||||
"""Test updating borrowing notes"""
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(**sample_ausleihung_data)
|
|
||||||
new_notes = 'Updated notes'
|
|
||||||
|
|
||||||
ausleihung.update_ausleihung(id=ausleihung_id, notes=new_notes)
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored['Notes'] == new_notes
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Complete and Cancel Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestCompleteAusleihung:
|
|
||||||
"""Test completing borrowings"""
|
|
||||||
|
|
||||||
def test_complete_ausleihung(self, test_db, sample_ausleihung_data):
|
|
||||||
"""Test marking a borrowing as completed"""
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(**sample_ausleihung_data)
|
|
||||||
|
|
||||||
end_time = datetime.datetime.now()
|
|
||||||
ausleihung.complete_ausleihung(ausleihung_id, end_time=end_time)
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored['Status'] == 'completed'
|
|
||||||
assert stored['End'] == end_time or stored['End'] is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCancelAusleihung:
|
|
||||||
"""Test canceling borrowings"""
|
|
||||||
|
|
||||||
def test_cancel_ausleihung(self, test_db, sample_ausleihung_data):
|
|
||||||
"""Test canceling a borrowing"""
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(**sample_ausleihung_data)
|
|
||||||
|
|
||||||
ausleihung.cancel_ausleihung(ausleihung_id)
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored['Status'] == 'cancelled'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Query Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestGetAusleihung:
|
|
||||||
"""Test retrieving borrowings"""
|
|
||||||
|
|
||||||
def test_get_ausleihung_by_id(self, test_db, sample_ausleihung_data):
|
|
||||||
"""Test fetching a borrowing by ID"""
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(**sample_ausleihung_data)
|
|
||||||
|
|
||||||
retrieved = ausleihung.get_ausleihung(ausleihung_id)
|
|
||||||
assert retrieved is not None
|
|
||||||
assert retrieved['_id'] == ausleihung_id
|
|
||||||
assert retrieved['User'] == sample_ausleihung_data['user']
|
|
||||||
|
|
||||||
def test_get_ausleihung_by_user(self, test_db):
|
|
||||||
"""Test retrieving all borrowings for a user"""
|
|
||||||
user = 'test_user_xyz'
|
|
||||||
item1 = str(ObjectId())
|
|
||||||
item2 = str(ObjectId())
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
# Create multiple borrowings for same user
|
|
||||||
ausleihung.add_ausleihung(
|
|
||||||
item_id=item1,
|
|
||||||
user=user,
|
|
||||||
start_date=now,
|
|
||||||
end_date=now + datetime.timedelta(hours=1),
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
ausleihung.add_ausleihung(
|
|
||||||
item_id=item2,
|
|
||||||
user=user,
|
|
||||||
start_date=now + datetime.timedelta(days=1),
|
|
||||||
end_date=now + datetime.timedelta(days=1, hours=1),
|
|
||||||
status='planned'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Retrieve all for user
|
|
||||||
borrowings = ausleihung.get_ausleihung_by_user(user)
|
|
||||||
assert len(borrowings) >= 2
|
|
||||||
assert all(b['User'] == user for b in borrowings)
|
|
||||||
|
|
||||||
def test_get_ausleihungen_by_status(self, test_db):
|
|
||||||
"""Test retrieving borrowings by status"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
# Create active
|
|
||||||
active_id = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=now - datetime.timedelta(hours=1),
|
|
||||||
end_date=now + datetime.timedelta(hours=1),
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get active borrowings
|
|
||||||
active_borrowings = ausleihung.get_active_ausleihungen()
|
|
||||||
assert any(b['_id'] == active_id for b in active_borrowings)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Conflict Detection Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestConflictDetection:
|
|
||||||
"""Test detecting overlapping/conflicting borrowings"""
|
|
||||||
|
|
||||||
def test_no_conflict_different_items(self, test_db):
|
|
||||||
"""Test that different items don't conflict"""
|
|
||||||
item1 = str(ObjectId())
|
|
||||||
item2 = str(ObjectId())
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
start = now
|
|
||||||
end = now + datetime.timedelta(hours=1)
|
|
||||||
|
|
||||||
# Create first borrowing
|
|
||||||
ausleihung.add_ausleihung(
|
|
||||||
item_id=item1,
|
|
||||||
user='user1',
|
|
||||||
start_date=start,
|
|
||||||
end_date=end,
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check conflict on different item (should be no conflict)
|
|
||||||
conflict = ausleihung.check_ausleihung_conflict(
|
|
||||||
item_id=item2,
|
|
||||||
start_date=start,
|
|
||||||
end_date=end
|
|
||||||
)
|
|
||||||
assert conflict is False
|
|
||||||
|
|
||||||
def test_conflict_same_item_overlapping(self, test_db):
|
|
||||||
"""Test that overlapping borrowings on same item are detected"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
# Create first borrowing
|
|
||||||
ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user='user1',
|
|
||||||
start_date=now,
|
|
||||||
end_date=now + datetime.timedelta(hours=2),
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create overlapping borrowing
|
|
||||||
conflict = ausleihung.check_ausleihung_conflict(
|
|
||||||
item_id=item_id,
|
|
||||||
start_date=now + datetime.timedelta(minutes=30),
|
|
||||||
end_date=now + datetime.timedelta(hours=3)
|
|
||||||
)
|
|
||||||
assert conflict is True or conflict == item_id # Depending on implementation
|
|
||||||
|
|
||||||
def test_no_conflict_different_times(self, test_db):
|
|
||||||
"""Test that non-overlapping borrowings don't conflict"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
# Create first borrowing
|
|
||||||
ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user='user1',
|
|
||||||
start_date=now,
|
|
||||||
end_date=now + datetime.timedelta(hours=1),
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check borrowing after first ends (should be no conflict)
|
|
||||||
conflict = ausleihung.check_ausleihung_conflict(
|
|
||||||
item_id=item_id,
|
|
||||||
start_date=now + datetime.timedelta(hours=2),
|
|
||||||
end_date=now + datetime.timedelta(hours=3)
|
|
||||||
)
|
|
||||||
assert conflict is False or conflict is None
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Period-based Borrowing Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestPeriodBookings:
|
|
||||||
"""Test period-based borrowings (school periods)"""
|
|
||||||
|
|
||||||
def test_create_period_booking(self, test_db):
|
|
||||||
"""Test creating a borrowing for a specific school period"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
today = datetime.datetime.now().date()
|
|
||||||
|
|
||||||
result = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=datetime.datetime.combine(today, datetime.time(8, 0)),
|
|
||||||
end_date=datetime.datetime.combine(today, datetime.time(9, 0)),
|
|
||||||
period=1, # Assuming period 1 is first period
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': result})
|
|
||||||
assert stored.get('Period') == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Remove Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestRemoveAusleihung:
|
|
||||||
"""Test removing/deleting borrowings"""
|
|
||||||
|
|
||||||
def test_remove_ausleihung(self, test_db, sample_ausleihung_data):
|
|
||||||
"""Test deleting a borrowing record (soft delete)"""
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(**sample_ausleihung_data)
|
|
||||||
stored_before = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored_before is not None
|
|
||||||
|
|
||||||
# Remove (soft delete - adds DeletedAt timestamp)
|
|
||||||
ausleihung.remove_ausleihung(ausleihung_id)
|
|
||||||
|
|
||||||
# Verify it's marked as deleted (soft delete)
|
|
||||||
stored_after = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored_after is not None # Still exists
|
|
||||||
assert 'DeletedAt' in stored_after or stored_after.get('Status') == 'deleted'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Integration Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestAusleihungLifecycle:
|
|
||||||
"""Test complete borrowing lifecycle"""
|
|
||||||
|
|
||||||
def test_full_lifecycle_active_to_complete(self, test_db):
|
|
||||||
"""Test a complete borrowing lifecycle: active → complete"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
# 1. Create active borrowing
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=now - datetime.timedelta(hours=1),
|
|
||||||
end_date=now + datetime.timedelta(hours=1),
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
status = ausleihung.get_current_status(stored)
|
|
||||||
assert status == 'active'
|
|
||||||
|
|
||||||
# 2. Complete the borrowing
|
|
||||||
ausleihung.complete_ausleihung(ausleihung_id)
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
status = ausleihung.get_current_status(stored)
|
|
||||||
assert status == 'completed'
|
|
||||||
|
|
||||||
def test_full_lifecycle_planned_to_active_to_complete(self, test_db):
|
|
||||||
"""Test complete lifecycle: planned → active → complete"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
future = now + datetime.timedelta(hours=1)
|
|
||||||
|
|
||||||
# 1. Create planned borrowing
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=future,
|
|
||||||
end_date=future + datetime.timedelta(hours=1),
|
|
||||||
status='planned'
|
|
||||||
)
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert ausleihung.get_current_status(stored) == 'planned'
|
|
||||||
|
|
||||||
# 2. Update to active (simulate time passing or manual activation)
|
|
||||||
ausleihung.update_ausleihung(id=ausleihung_id, status='active')
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored['Status'] == 'active'
|
|
||||||
|
|
||||||
# 3. Complete
|
|
||||||
ausleihung.complete_ausleihung(ausleihung_id)
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert ausleihung.get_current_status(stored) == 'completed'
|
|
||||||
|
|
||||||
def test_cancel_planned_borrowing(self, test_db):
|
|
||||||
"""Test canceling a planned borrowing"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
future = datetime.datetime.now() + datetime.timedelta(days=1)
|
|
||||||
|
|
||||||
# Create planned
|
|
||||||
ausleihung_id = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=future,
|
|
||||||
end_date=future + datetime.timedelta(hours=1),
|
|
||||||
status='planned'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cancel
|
|
||||||
ausleihung.cancel_ausleihung(ausleihung_id)
|
|
||||||
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': ausleihung_id})
|
|
||||||
assert stored['Status'] == 'cancelled'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Edge Cases
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestEdgeCases:
|
|
||||||
"""Test edge cases and boundary conditions"""
|
|
||||||
|
|
||||||
def test_borrowing_with_same_start_and_end(self, test_db):
|
|
||||||
"""Test borrowing where start equals end"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
result = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=now,
|
|
||||||
end_date=now, # Same time
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
def test_borrowing_without_end_date(self, test_db):
|
|
||||||
"""Test creating borrowing without end date"""
|
|
||||||
item_id = str(ObjectId())
|
|
||||||
user = 'test_user'
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
result = ausleihung.add_ausleihung(
|
|
||||||
item_id=item_id,
|
|
||||||
user=user,
|
|
||||||
start_date=now,
|
|
||||||
end_date=None,
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
stored = test_db['ausleihungen'].find_one({'_id': result})
|
|
||||||
# End field should not exist or be None if not provided
|
|
||||||
assert 'End' not in stored or stored.get('End') is None
|
|
||||||
|
|
||||||
def test_get_nonexistent_borrowing(self, test_db):
|
|
||||||
"""Test retrieving a nonexistent borrowing"""
|
|
||||||
fake_id = ObjectId()
|
|
||||||
result = ausleihung.get_ausleihung(fake_id)
|
|
||||||
assert result is None or result == {} or result == []
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Run Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
pytest.main([__file__, '-v', '--tb=short'])
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from Web.app import app
|
|
||||||
with app.test_client() as client:
|
|
||||||
resp = client.get('/terminplan')
|
|
||||||
print(resp.status_code)
|
|
||||||
# print(resp.data.decode('utf-8')[:200])
|
|
||||||
@@ -18,6 +18,7 @@ DIST_DIR="$PROJECT_DIR/dist"
|
|||||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
||||||
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
|
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
|
||||||
|
MODE="release"
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||||
@@ -152,6 +153,7 @@ Usage: $0 [options]
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--multitenant Use docker-compose-multitenant.yml (default)
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
development Install development build from GHCR or local dist
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -163,6 +165,10 @@ parse_args() {
|
|||||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
development|dev)
|
||||||
|
MODE="development"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@@ -480,6 +486,54 @@ main() {
|
|||||||
archive_logs
|
archive_logs
|
||||||
create_backup
|
create_backup
|
||||||
|
|
||||||
|
# If user requested a development install, perform a simple dev deploy flow
|
||||||
|
if [ "$MODE" = "development" ]; then
|
||||||
|
log_message "Requested development install"
|
||||||
|
local tag="dev"
|
||||||
|
local app_image="$APP_IMAGE_REPO:$tag"
|
||||||
|
local compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
|
||||||
|
if [ ! -f "$compose_path" ]; then
|
||||||
|
log_message "ERROR: compose file not found: $compose_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure ENV_FILE contains the development image
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
NUITKA_BUILD=0
|
||||||
|
INVENTAR_HTTP_PORT=10000
|
||||||
|
INVENTAR_APP_IMAGE=$app_image
|
||||||
|
EOF
|
||||||
|
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
|
||||||
|
sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=$app_image|" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try local dist first, then pull from GHCR
|
||||||
|
if ! load_local_dist_image "$tag"; then
|
||||||
|
log_message "Attempting to pull development image $app_image"
|
||||||
|
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
||||||
|
log_message "ERROR: Could not obtain development image $app_image"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bring up stack
|
||||||
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1 || true
|
||||||
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
|
if ! verify_stack_health; then
|
||||||
|
log_message "ERROR: Development deployment failed health check"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$tag" > "$STATE_FILE"
|
||||||
|
log_message "Development update completed successfully"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
local tmp_dir meta_file latest_tag current_tag bundle_url
|
local tmp_dir meta_file latest_tag current_tag bundle_url
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
meta_file="$tmp_dir/release.json"
|
meta_file="$tmp_dir/release.json"
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Invario PDF Audit Export - Implementation Verification Script
|
|
||||||
|
|
||||||
This script verifies that all components of the PDF audit export system
|
|
||||||
are correctly installed and configured.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
def verify_files():
|
|
||||||
"""Verify all required files exist."""
|
|
||||||
print("=" * 60)
|
|
||||||
print("INVARIO PDF AUDIT EXPORT - INSTALLATION VERIFICATION")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
files_to_check = {
|
|
||||||
"Core Module": [
|
|
||||||
"Web/pdf_audit_export.py",
|
|
||||||
],
|
|
||||||
"Flask Integration": [
|
|
||||||
"Web/app.py", # Should contain new routes
|
|
||||||
"Web/templates/admin_audit.html", # Should contain new buttons
|
|
||||||
],
|
|
||||||
"Documentation": [
|
|
||||||
"PDF_AUDIT_EXPORT_DOCUMENTATION.md",
|
|
||||||
"PDF_IMPLEMENTATION_GUIDE.md",
|
|
||||||
"QUICK_START_PDF_EXPORT.md",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
base_path = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
all_ok = True
|
|
||||||
|
|
||||||
for category, files in files_to_check.items():
|
|
||||||
print(f"\n[{category}]")
|
|
||||||
for filename in files:
|
|
||||||
filepath = os.path.join(base_path, filename)
|
|
||||||
exists = os.path.exists(filepath)
|
|
||||||
status = "✓" if exists else "✗"
|
|
||||||
print(f" {status} {filename}")
|
|
||||||
if not exists:
|
|
||||||
all_ok = False
|
|
||||||
|
|
||||||
return all_ok
|
|
||||||
|
|
||||||
def verify_dependencies():
|
|
||||||
"""Verify Python dependencies are installed."""
|
|
||||||
print("\n[Python Dependencies]")
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("reportlab", "PDF generation"),
|
|
||||||
("qrcode", "QR code generation"),
|
|
||||||
("pillow", "Image processing"),
|
|
||||||
("flask", "Web framework"),
|
|
||||||
("pymongo", "MongoDB driver"),
|
|
||||||
]
|
|
||||||
|
|
||||||
all_ok = True
|
|
||||||
for package, description in dependencies:
|
|
||||||
try:
|
|
||||||
__import__(package)
|
|
||||||
print(f" ✓ {package:15} - {description}")
|
|
||||||
except ImportError:
|
|
||||||
print(f" ✗ {package:15} - {description}")
|
|
||||||
all_ok = False
|
|
||||||
|
|
||||||
return all_ok
|
|
||||||
|
|
||||||
def verify_routes():
|
|
||||||
"""Verify new routes are defined in app.py."""
|
|
||||||
print("\n[Flask Routes]")
|
|
||||||
|
|
||||||
routes_to_check = [
|
|
||||||
"/admin/audit/export/pdf/quick",
|
|
||||||
"/admin/audit/export/pdf/official",
|
|
||||||
]
|
|
||||||
|
|
||||||
app_py_path = "Web/app.py"
|
|
||||||
if not os.path.exists(app_py_path):
|
|
||||||
print(" ✗ app.py not found")
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(app_py_path, 'r') as f:
|
|
||||||
app_content = f.read()
|
|
||||||
|
|
||||||
all_ok = True
|
|
||||||
for route in routes_to_check:
|
|
||||||
if route in app_content:
|
|
||||||
print(f" ✓ {route}")
|
|
||||||
else:
|
|
||||||
print(f" ✗ {route}")
|
|
||||||
all_ok = False
|
|
||||||
|
|
||||||
return all_ok
|
|
||||||
|
|
||||||
def verify_template():
|
|
||||||
"""Verify template updates are in place."""
|
|
||||||
print("\n[HTML Template Updates]")
|
|
||||||
|
|
||||||
template_path = "Web/templates/admin_audit.html"
|
|
||||||
if not os.path.exists(template_path):
|
|
||||||
print(" ✗ admin_audit.html not found")
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(template_path, 'r') as f:
|
|
||||||
template_content = f.read()
|
|
||||||
|
|
||||||
checks = {
|
|
||||||
"DIN 5008 Info Box": "DIN 5008",
|
|
||||||
"PDF Quick-Check Button": "admin_audit_export_pdf_quick",
|
|
||||||
"PDF Official Button": "admin_audit_export_pdf_official",
|
|
||||||
}
|
|
||||||
|
|
||||||
all_ok = True
|
|
||||||
for check_name, check_string in checks.items():
|
|
||||||
if check_string in template_content:
|
|
||||||
print(f" ✓ {check_name}")
|
|
||||||
else:
|
|
||||||
print(f" ✗ {check_name}")
|
|
||||||
all_ok = False
|
|
||||||
|
|
||||||
return all_ok
|
|
||||||
|
|
||||||
def get_statistics():
|
|
||||||
"""Get implementation statistics."""
|
|
||||||
print("\n[Implementation Statistics]")
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
"Web/pdf_audit_export.py": "PDF Export Module",
|
|
||||||
"PDF_AUDIT_EXPORT_DOCUMENTATION.md": "Technical Documentation",
|
|
||||||
"PDF_IMPLEMENTATION_GUIDE.md": "Implementation Guide",
|
|
||||||
"QUICK_START_PDF_EXPORT.md": "Quick Start Guide",
|
|
||||||
}
|
|
||||||
|
|
||||||
total_lines = 0
|
|
||||||
for filename, description in stats.items():
|
|
||||||
if os.path.exists(filename):
|
|
||||||
with open(filename, 'r') as f:
|
|
||||||
lines = len(f.readlines())
|
|
||||||
print(f" {filename:45} {lines:5} lines - {description}")
|
|
||||||
total_lines += lines
|
|
||||||
|
|
||||||
print(f"\n Total documentation: {total_lines} lines")
|
|
||||||
|
|
||||||
def print_next_steps():
|
|
||||||
"""Print next steps for the user."""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("NEXT STEPS")
|
|
||||||
print("=" * 60)
|
|
||||||
print("""
|
|
||||||
1. CONFIGURE SCHOOL INFO (Optional)
|
|
||||||
Edit config.json and add:
|
|
||||||
{
|
|
||||||
"school": {
|
|
||||||
"name": "Your School Name",
|
|
||||||
"address": "School Address",
|
|
||||||
"postal_code": "12345",
|
|
||||||
"city": "City Name",
|
|
||||||
"school_number": "123456",
|
|
||||||
"it_admin": "Admin Name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
2. RESTART THE SYSTEM
|
|
||||||
./start.sh
|
|
||||||
or: python Web/app.py
|
|
||||||
|
|
||||||
3. TEST PDF EXPORTS
|
|
||||||
- Login to http://localhost:8000/admin/audit
|
|
||||||
- Click "🚀 Schnell-Check" for compact PDF
|
|
||||||
- Click "📋 Amtlicher Bericht" for full DIN 5008 report
|
|
||||||
|
|
||||||
4. VERIFY COMPLIANCE
|
|
||||||
- Check PDF opens in your PDF reader
|
|
||||||
- Verify school info is correct
|
|
||||||
- Test signature fields
|
|
||||||
- Verify barrierefreiheit (accessibility)
|
|
||||||
|
|
||||||
5. DEPLOY TO PRODUCTION
|
|
||||||
- Update your deployment scripts
|
|
||||||
- Document new export procedures
|
|
||||||
- Train staff on Quick-Check vs Official Report
|
|
||||||
""")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run all verifications."""
|
|
||||||
print()
|
|
||||||
|
|
||||||
checks = [
|
|
||||||
("File Structure", verify_files),
|
|
||||||
("Dependencies", verify_dependencies),
|
|
||||||
("Flask Routes", verify_routes),
|
|
||||||
("HTML Template", verify_template),
|
|
||||||
]
|
|
||||||
|
|
||||||
all_passed = True
|
|
||||||
for name, check_func in checks:
|
|
||||||
try:
|
|
||||||
passed = check_func()
|
|
||||||
if not passed:
|
|
||||||
all_passed = False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n✗ Error during {name} check: {e}")
|
|
||||||
all_passed = False
|
|
||||||
|
|
||||||
# Get statistics
|
|
||||||
get_statistics()
|
|
||||||
|
|
||||||
# Print results
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
if all_passed:
|
|
||||||
print("✓ ALL VERIFICATION CHECKS PASSED!")
|
|
||||||
print("=" * 60)
|
|
||||||
print_next_steps()
|
|
||||||
else:
|
|
||||||
print("✗ SOME VERIFICATION CHECKS FAILED")
|
|
||||||
print("=" * 60)
|
|
||||||
print("\nPlease check the errors above and verify:")
|
|
||||||
print("1. All files are in the correct locations")
|
|
||||||
print("2. All dependencies are installed")
|
|
||||||
print("3. app.py and admin_audit.html have been updated")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("For detailed documentation, see:")
|
|
||||||
print(" - QUICK_START_PDF_EXPORT.md (Start here!)")
|
|
||||||
print(" - PDF_IMPLEMENTATION_GUIDE.md (Technical details)")
|
|
||||||
print(" - PDF_AUDIT_EXPORT_DOCUMENTATION.md (Requirements)")
|
|
||||||
print("=" * 60 + "\n")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user