Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f847faea3e | |||
| 4c58dceb02 | |||
| db80693c6a | |||
| d451d58fc4 | |||
| 726520c9de | |||
| fabac77877 | |||
| bb2832c273 | |||
| ed122995ee | |||
| 634feda9da | |||
| fb8be06e81 | |||
| 6ad1720461 | |||
| 0c27d7ac86 | |||
| b3fc470c88 |
@@ -14,6 +14,16 @@ on:
|
||||
options:
|
||||
- patch
|
||||
- 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:
|
||||
contents: write
|
||||
@@ -38,57 +48,46 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
||||
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||
TAG="$REF_NAME"
|
||||
else
|
||||
TAG="$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
||||
latest_tag="v3.0.0"
|
||||
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
|
||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
if [ -n "$tag_name" ]; then
|
||||
latest_tag="$tag_name"
|
||||
fi
|
||||
fi
|
||||
|
||||
repo = os.environ["REPO"]
|
||||
token = os.environ.get("GH_TOKEN", "")
|
||||
bump = os.environ.get("BUMP_TYPE", "patch").strip().lower()
|
||||
if [[ "$latest_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
major=${BASH_REMATCH[1]}
|
||||
minor=${BASH_REMATCH[2]}
|
||||
patch=${BASH_REMATCH[3]}
|
||||
else
|
||||
major=3; minor=0; patch=0
|
||||
fi
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
# 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
|
||||
|
||||
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
|
||||
)"
|
||||
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]+$'; then
|
||||
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -105,21 +104,35 @@ jobs:
|
||||
LATEST_MAJOR="3"
|
||||
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)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; do
|
||||
BASE="${TAG%.*}"
|
||||
PATCH="${TAG##*.}"
|
||||
PATCH="$((PATCH + 1))"
|
||||
TAG="$BASE.$PATCH"
|
||||
done
|
||||
# Ensure tag uniqueness: if tag exists append numeric suffix
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
i=1
|
||||
base="$TAG"
|
||||
while git rev-parse -q --verify "refs/tags/${base}.${i}" >/dev/null; do
|
||||
i="$((i + 1))"
|
||||
done
|
||||
TAG="${base}.${i}"
|
||||
fi
|
||||
|
||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
||||
echo "tag=$TAG" >> "$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: Create and push tag for manual releases
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
@@ -141,6 +154,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push release image
|
||||
if: steps.meta.outputs.is_development != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
@@ -150,10 +164,39 @@ jobs:
|
||||
${{ steps.meta.outputs.image }}
|
||||
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
|
||||
run: |
|
||||
docker pull "${{ steps.meta.outputs.image }}"
|
||||
docker save "${{ steps.meta.outputs.image }}" | gzip > "inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz"
|
||||
set -euo pipefail
|
||||
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: Create release-only docker bundle
|
||||
run: |
|
||||
@@ -224,29 +267,43 @@ jobs:
|
||||
redis_data:
|
||||
EOF
|
||||
|
||||
cp start.sh release-bundle/start.sh
|
||||
cp stop.sh release-bundle/stop.sh
|
||||
cp restart.sh release-bundle/restart.sh
|
||||
cp backup.sh release-bundle/backup.sh
|
||||
cp config.json release-bundle/config.json
|
||||
cp update.sh release-bundle/update.sh
|
||||
# Copy runtime scripts and config if present
|
||||
for f in start.sh stop.sh restart.sh backup.sh config.json update.sh; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" "release-bundle/$(basename "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
# Multitenant scripts & docs
|
||||
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
|
||||
cp manage-tenant.sh release-bundle/manage-tenant.sh
|
||||
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
|
||||
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
|
||||
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
|
||||
# Multitenant scripts & docs (optional)
|
||||
for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" "release-bundle/$(basename "$f")"
|
||||
fi
|
||||
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 .
|
||||
|
||||
- name: Create or update GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
prerelease: ${{ steps.meta.outputs.is_development }}
|
||||
files: |
|
||||
inventarsystem-docker-bundle.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
|
||||
|
||||
@@ -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
|
||||
+64
-18
@@ -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 werkzeug.utils import secure_filename
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from werkzeug.routing import BuildError
|
||||
from jinja2 import TemplateNotFound
|
||||
import user as us
|
||||
import items as it
|
||||
import ausleihung as au
|
||||
import audit_log as al
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 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.logs.audit_log as al
|
||||
import push_notifications as pn
|
||||
import pdf_export
|
||||
import Web.modules.inventarsystem.pdf_export as pdf_export
|
||||
import datetime
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from bson.objectid import ObjectId
|
||||
@@ -42,7 +55,6 @@ from urllib.parse import urlparse, urlunparse
|
||||
import requests
|
||||
import csv
|
||||
import ipaddress
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import time
|
||||
@@ -63,13 +75,12 @@ except Exception:
|
||||
# import qrcode
|
||||
# from qrcode.constants import ERROR_CORRECT_L
|
||||
import threading
|
||||
import sys
|
||||
import shutil
|
||||
import uuid
|
||||
from PIL import Image, ImageOps
|
||||
import mimetypes
|
||||
import subprocess
|
||||
from data_protection import (
|
||||
from Web.modules.inventarsystem.data_protection import (
|
||||
decrypt_document_fields,
|
||||
encrypt_document_fields,
|
||||
encrypt_soft_deleted_media_pack,
|
||||
@@ -77,8 +88,8 @@ from data_protection import (
|
||||
|
||||
# Set base directory and centralized settings
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from tenant import get_tenant_context
|
||||
|
||||
|
||||
@@ -373,7 +384,7 @@ def _enforce_module_access():
|
||||
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
|
||||
return redirect(url_for('home'))
|
||||
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'))
|
||||
|
||||
@@ -433,6 +444,41 @@ def handle_not_found(e):
|
||||
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.'):
|
||||
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
|
||||
@@ -2640,7 +2686,7 @@ def home():
|
||||
|
||||
if not cfg.MODULES.is_enabled('inventory'):
|
||||
if cfg.MODULES.is_enabled('library'):
|
||||
return redirect(url_for('library_view'))
|
||||
return redirect('/library')
|
||||
else:
|
||||
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
|
||||
|
||||
@@ -2716,17 +2762,16 @@ def tutorial_page():
|
||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
||||
)
|
||||
|
||||
@app.route('/library')
|
||||
|
||||
@app.route('/library/export', defaults={'scope': 'all'})
|
||||
@app.route('/library/export/<scope>')
|
||||
def library_export_excel(scope):
|
||||
def library_export_excel(scope='all'):
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
username = session['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)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items_collection = db['items']
|
||||
@@ -2739,7 +2784,7 @@ def library_export_excel(scope):
|
||||
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
|
||||
elif scope == 'all_borrowed':
|
||||
if not is_admin_user:
|
||||
return redirect(url_for('library_view'))
|
||||
return redirect('/library')
|
||||
query['Verfuegbar'] = False
|
||||
filename = "Bibliothek_Alle_Ausleihen.xlsx"
|
||||
elif scope == 'schulbuecher':
|
||||
@@ -2760,6 +2805,7 @@ def library_export_excel(scope):
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
@app.route('/library')
|
||||
def library_view():
|
||||
"""
|
||||
Dedicated page for viewing library items (books, CDs, etc.).
|
||||
@@ -10105,7 +10151,7 @@ def reset_item(id):
|
||||
|
||||
try:
|
||||
# Import the ausleihung module
|
||||
import ausleihung as au
|
||||
import Web.modules.database.ausleihung as au
|
||||
|
||||
result = au.reset_item_completely(id)
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
Tamper-evident audit logging helpers.
|
||||
|
||||
The audit chain stores each entry with a hash of the previous entry.
|
||||
Any mutation in history breaks the chain verification.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
|
||||
def _stable_json(value):
|
||||
"""Serialize dictionaries in a stable way for deterministic hashing."""
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _entry_hash(prev_hash, payload):
|
||||
"""Build the chained entry hash from previous hash + canonical payload."""
|
||||
base = f"{prev_hash}|{_stable_json(payload)}"
|
||||
return hashlib.sha256(base.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
|
||||
"""
|
||||
Append an audit event to a tamper-evident chain.
|
||||
|
||||
Args:
|
||||
db: MongoDB database handle.
|
||||
event_type (str): Event category.
|
||||
actor (str): User/system who performed the action.
|
||||
payload (dict): Event details.
|
||||
request_ip (str, optional): Request origin.
|
||||
source (str): Source subsystem.
|
||||
|
||||
Returns:
|
||||
dict: Inserted audit entry.
|
||||
"""
|
||||
logs = db["audit_log"]
|
||||
attempts = 0
|
||||
|
||||
while attempts <= max_retries:
|
||||
previous = logs.find_one(sort=[("chain_index", -1)])
|
||||
prev_hash = previous.get("entry_hash", "") if previous else ""
|
||||
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
|
||||
|
||||
timestamp = datetime.datetime.utcnow()
|
||||
entry_payload = {
|
||||
"event_type": event_type,
|
||||
"actor": actor or "system",
|
||||
"source": source,
|
||||
"ip": request_ip or "",
|
||||
"payload": payload or {},
|
||||
"timestamp": timestamp.isoformat() + "Z",
|
||||
}
|
||||
|
||||
entry_hash = _entry_hash(prev_hash, entry_payload)
|
||||
|
||||
entry = {
|
||||
**entry_payload,
|
||||
"created_at": timestamp,
|
||||
"prev_hash": prev_hash,
|
||||
"entry_hash": entry_hash,
|
||||
"chain_index": chain_index,
|
||||
}
|
||||
|
||||
try:
|
||||
logs.insert_one(entry)
|
||||
return entry
|
||||
except DuplicateKeyError:
|
||||
attempts += 1
|
||||
if attempts > max_retries:
|
||||
raise
|
||||
# Exponential backoff with jitter to avoid retry storms.
|
||||
delay = min(0.25, (0.005 * (2 ** attempts)) + random.random() * 0.01)
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def ensure_audit_indexes(db):
|
||||
"""Create indexes required for fast and safe audit operations."""
|
||||
logs = db["audit_log"]
|
||||
logs.create_index("chain_index", unique=True, name="audit_chain_index_unique")
|
||||
logs.create_index("created_at", name="audit_created_at_idx")
|
||||
logs.create_index("event_type", name="audit_event_type_idx")
|
||||
|
||||
|
||||
def verify_audit_chain(db):
|
||||
"""Verify hash chain integrity across all stored audit entries."""
|
||||
logs = db["audit_log"]
|
||||
entries = list(logs.find({}, {"_id": 1, "event_type": 1, "actor": 1, "source": 1, "ip": 1, "payload": 1, "timestamp": 1, "prev_hash": 1, "entry_hash": 1, "chain_index": 1}).sort("chain_index", 1))
|
||||
|
||||
previous_hash = ""
|
||||
previous_index = 0
|
||||
mismatches = []
|
||||
|
||||
for entry in entries:
|
||||
chain_index = int(entry.get("chain_index", 0))
|
||||
prev_hash = entry.get("prev_hash", "")
|
||||
entry_hash = entry.get("entry_hash", "")
|
||||
|
||||
payload = {
|
||||
"event_type": entry.get("event_type", ""),
|
||||
"actor": entry.get("actor", ""),
|
||||
"source": entry.get("source", ""),
|
||||
"ip": entry.get("ip", ""),
|
||||
"payload": entry.get("payload", {}),
|
||||
"timestamp": entry.get("timestamp", ""),
|
||||
}
|
||||
|
||||
expected_hash = _entry_hash(previous_hash, payload)
|
||||
|
||||
if chain_index != previous_index + 1:
|
||||
mismatches.append({
|
||||
"chain_index": chain_index,
|
||||
"error": "chain_index_gap",
|
||||
"expected": previous_index + 1,
|
||||
"found": chain_index,
|
||||
})
|
||||
|
||||
if prev_hash != previous_hash:
|
||||
mismatches.append({
|
||||
"chain_index": chain_index,
|
||||
"error": "prev_hash_mismatch",
|
||||
"expected": previous_hash,
|
||||
"found": prev_hash,
|
||||
})
|
||||
|
||||
if entry_hash != expected_hash:
|
||||
mismatches.append({
|
||||
"chain_index": chain_index,
|
||||
"error": "entry_hash_mismatch",
|
||||
"expected": expected_hash,
|
||||
"found": entry_hash,
|
||||
})
|
||||
|
||||
previous_hash = entry_hash
|
||||
previous_index = chain_index
|
||||
|
||||
return {
|
||||
"ok": len(mismatches) == 0,
|
||||
"count": len(entries),
|
||||
"last_chain_index": previous_index,
|
||||
"last_hash": previous_hash,
|
||||
"mismatches": mismatches,
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
'''
|
||||
Copyright 2025-2026 AIIrondev
|
||||
|
||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||
See Legal/LICENSE for the full license text.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
"""
|
||||
Funktion zum Protokollieren von Statusänderungen bei Ausleihungen
|
||||
"""
|
||||
import os
|
||||
import datetime
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
||||
"""
|
||||
Protokolliert eine Statusänderung einer Ausleihung in einer Log-Datei.
|
||||
|
||||
Args:
|
||||
ausleihung_id: Die ID der Ausleihung
|
||||
old_status: Der alte Status
|
||||
new_status: Der neue Status
|
||||
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
|
||||
"""
|
||||
try:
|
||||
# Erstelle Log-Verzeichnis, falls es nicht existiert
|
||||
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
# Log-Datei für Statusänderungen
|
||||
log_file = os.path.join(log_dir, 'ausleihungen_status_changes.log')
|
||||
|
||||
# Protokolliere die Änderung
|
||||
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
user_info = f" by {user}" if user else ""
|
||||
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Protokollieren der Statusänderung: {e}")
|
||||
return False
|
||||
@@ -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 json
|
||||
import shutil
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
# Add this helper function after imports
|
||||
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:
|
||||
try:
|
||||
# Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden
|
||||
import ausleihung_log
|
||||
import Web.modules.logs.ausleihung_log as ausleihung_log
|
||||
ausleihung_log.log_status_change(
|
||||
str(ausleihung['_id']),
|
||||
original_status,
|
||||
@@ -28,8 +28,8 @@ Collection Structure:
|
||||
'''
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
||||
@@ -135,7 +135,7 @@ def _get_int_env(name, default):
|
||||
return int(default)
|
||||
|
||||
def get_version():
|
||||
with open(os.path.join(BASE_DIR, '..', '.docker-build.env'), 'r') as f:
|
||||
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()
|
||||
@@ -211,7 +211,7 @@ class _TenantAwareBool:
|
||||
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']))
|
||||
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
@@ -1,5 +1,5 @@
|
||||
from pymongo import MongoClient
|
||||
import Web.settings as cfg
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
def get_filter_names():
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
@@ -17,8 +17,8 @@ import re
|
||||
import secrets
|
||||
import string
|
||||
from bson.objectid import ObjectId
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
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
|
||||
|
||||
import settings as cfg
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
_ENC_PREFIX = "enc::"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
import user
|
||||
import Web.modules.database.user as user
|
||||
import sys
|
||||
import getpass
|
||||
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.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
import settings as cfg
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
|
||||
__version__ = cfg.APP_VERSION
|
||||
@@ -0,0 +1 @@
|
||||
print("hello")
|
||||
@@ -11,8 +11,8 @@ import requests
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<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>
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ from functools import wraps
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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"
|
||||
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
||||
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
|
||||
MODE="release"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||
@@ -152,6 +153,7 @@ Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
development Install development build from GHCR or local dist
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
}
|
||||
@@ -163,6 +165,10 @@ parse_args() {
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
development|dev)
|
||||
MODE="development"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -480,6 +486,54 @@ main() {
|
||||
archive_logs
|
||||
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
|
||||
tmp_dir="$(mktemp -d)"
|
||||
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