Compare commits

..

59 Commits

Author SHA1 Message Date
github-actions[bot] 4e73d69245 chore: bump version to v0.7.78 2026-05-23 21:32:20 +00:00
Aiirondev_dev daffeaf379 feat: add trial tenant configuration and management to support auto-deletion 2026-05-23 23:08:32 +02:00
Aiirondev_dev a1ad354ccf chore: remove outdated copyright notice from app.py 2026-05-20 23:01:25 +02:00
github-actions[bot] d4d5cd6436 chore: bump version to v0.7.77 2026-05-20 19:45:38 +00:00
Aiirondev_dev 9fd9d63e31 feat: add configuration path environment variable for app 2026-05-20 21:45:19 +02:00
Aiirondev_dev 08cce1b884 feat: enhance configuration path resolution for Docker deployments 2026-05-20 21:39:56 +02:00
github-actions[bot] adde73dffb chore: bump version to v0.7.76 2026-05-20 19:18:10 +00:00
Aiirondev_dev da17667d90 feat: enhance tenant resolution logic with host candidate handling 2026-05-20 21:17:12 +02:00
github-actions[bot] c69231fa77 chore: bump version to v0.7.75 2026-05-20 18:52:46 +00:00
Aiirondev_dev ac43178b29 feat: implement tenant database resolution and configuration checks 2026-05-20 20:52:06 +02:00
github-actions[bot] a2d58208e6 chore: bump version to v0.7.74 2026-05-20 18:40:06 +00:00
Aiirondev_dev f8171a5f18 feat: enhance tenant aliasing for multi-tenant management in scripts 2026-05-20 20:39:45 +02:00
github-actions[bot] 6f898ffea4 chore: bump version to v0.7.73 2026-05-20 18:12:28 +00:00
Aiirondev_dev b29cc38a3d fix: enhance tenant ID management in session for multi-tenant support 2026-05-20 20:12:01 +02:00
github-actions[bot] 17745c64e0 chore: bump version to v0.7.72 2026-05-20 17:37:17 +00:00
Aiirondev_dev a0279f82e1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-20 19:35:26 +02:00
Aiirondev_dev 8d1e3865d3 fix: improve environment variable handling and path resolution in restart_app_container function 2026-05-20 19:35:11 +02:00
github-actions[bot] 79be502aa1 chore: bump version to v0.7.71 2026-05-20 17:27:29 +00:00
Aiirondev_dev d0f54f0dca fix: ensure deterministic config path resolution in manage-tenant.sh 2026-05-20 19:26:00 +02:00
github-actions[bot] 2b00aab1fa chore: bump version to v0.7.70 2026-05-20 16:45:03 +00:00
Aiirondev_dev 6d4ac073a5 feat: Add module name candidate resolution for tenant feature modules 2026-05-20 18:42:55 +02:00
Aiirondev_dev 73ab1a37d2 feat: Update import paths for database settings in manage-tenant.sh 2026-05-20 18:31:17 +02:00
github-actions[bot] 0eb1ffe9d9 chore: bump version to v0.7.69 2026-05-20 16:24:14 +00:00
Aiirondev_dev 0ea1294237 feat: Update version handling in release workflow and improve directory creation 2026-05-20 18:23:34 +02:00
Aiirondev_dev a7ff2f1552 feat: Refactor logging module structure and implement audit logging functionality 2026-05-20 18:01:08 +02:00
Aiirondev_dev f847faea3e feat: Improve module import paths and add package initialization files 2026-05-20 17:43:06 +02:00
Aiirondev_dev 4c58dceb02 feat: Enhance error handling for URL resolution and unexpected exceptions 2026-05-20 17:06:22 +02:00
Aiirondev_dev db80693c6a fix: Update link to library admin in invoice template 2026-05-20 16:54:17 +02:00
Aiirondev_dev d451d58fc4 fix: Remove redundant conditional check for tag validation in release workflow 2026-05-20 16:40:56 +02:00
Aiirondev_dev 726520c9de feat: Simplify latest release tag fetching and bump strategy in release workflow 2026-05-20 16:36:53 +02:00
Aiirondev_dev fabac77877 feat: Add major version bump option and update release options documentation 2026-05-20 16:34:30 +02:00
Aiirondev_dev bb2832c273 feat: Remove local development image tar creation and update development bundle documentation 2026-05-20 16:32:27 +02:00
Aiirondev_dev ed122995ee feat: Add push_dev option for development image in release workflow and update tag handling 2026-05-20 16:26:09 +02:00
Aiirondev_dev 634feda9da feat: Refactor script copying to use loops for optional files in release bundle 2026-05-20 15:50:41 +02:00
Aiirondev_dev fb8be06e81 feat: Enhance version bump strategy and improve local image handling in release workflow 2026-05-20 15:40:27 +02:00
Aiirondev_dev 6ad1720461 feat: Add development bump option and update release conditions in workflow 2026-05-20 15:35:55 +02:00
Aiirondev_dev 0c27d7ac86 feat: Add Excel, PDF export, and user generation modules
- Implemented `excel_export.py` for generating library item exports in Excel format.
- Created `pdf_export.py` for generating audit reports compliant with DIN 5008 standards, including detailed event tables and signature blocks.
- Developed `generate_user.py` for interactive user creation with validation for usernames and passwords.
- Introduced `module_registry.py` for managing module states and path matching.
- Added a basic `__init__.py` in the `terminplaner` module for initialization.
2026-05-20 15:30:23 +02:00
Aiirondev_dev b3fc470c88 feat: add library route for viewing library items and reposition export route 2026-05-19 21:51:00 +02:00
Aiirondev_dev 541ca7172b feat: update get_version function to read application image from .docker-build.env 2026-05-19 21:06:44 +02:00
Aiirondev_dev 001a9fae17 feat: add get_version function to retrieve application version from .release-version file 2026-05-19 20:55:53 +02:00
Aiirondev_dev b572704381 feat: add runtime config initialization in manage-tenant.sh and update health check method in docker-compose 2026-05-19 20:53:50 +02:00
Aiirondev_dev 8b114b5b8e feat: enhance item card layout in table view for better spacing and alignment 2026-05-19 10:05:29 +02:00
Aiirondev_dev 96771d192c feat: add export functionality for library items and include 'schulbuch' type 2026-05-19 09:53:04 +02:00
Aiirondev_dev 073652c63d feat: optimize rootMargin for better performance based on layout 2026-05-18 23:38:13 +02:00
Aiirondev_dev 23d9d0871a feat: implement customizable filter names and update management interfaces 2026-05-18 23:20:52 +02:00
Aiirondev_dev 576d31a384 feat: add edit functionality for predefined filter values in filter management 2026-05-18 22:01:37 +02:00
Aiirondev_dev c43bb30301 feat: enhance bookmark and favorite button styles for dark mode 2026-05-18 18:30:14 +02:00
Aiirondev_dev 7eeea1d319 feat: update dark theme colors and add border radius for improved aesthetics 2026-05-18 17:49:42 +02:00
Aiirondev_dev 56486bcbb2 feat: update address details in impressum section 2026-05-18 17:39:19 +02:00
Aiirondev_dev 2c3fb779ce feat: update styles for dark mode and enhance filter handling in admin template 2026-05-18 17:35:02 +02:00
Aiirondev_dev 299d11c1d0 feat: implement DOM virtualization for item cards in main and admin templates 2026-05-18 17:06:46 +02:00
Aiirondev_dev 4d1f95cb1f feat: improve filter3 options handling and enhance item card visibility 2026-05-18 16:53:11 +02:00
Aiirondev_dev 6b197fd9d1 feat: add third filter management and update filter handling logic 2026-05-18 16:40:11 +02:00
Aiirondev_dev 58064ee249 Changes to be committed:
modified:   Web/user.py
2026-05-13 16:05:13 +02:00
Aiirondev_dev f7573b9a62 feat: remove back navigation link from upload admin template 2026-05-12 09:32:13 +02:00
Aiirondev_dev 85d758fadb feat: update application image version to v0.7.42 2026-05-12 09:08:38 +02:00
Aiirondev_dev 212dd2abcf feat: Add 404 error handling for missing templates and files 2026-05-12 09:07:37 +02:00
Aiirondev_dev 689819df08 feat: update application image version and enhance docker-compose configuration 2026-05-12 08:16:04 +02:00
Aiirondev_dev 457d32a087 feat: Module fallback redirects and duplicate item modal redirects 2026-05-12 08:00:02 +02:00
67 changed files with 2135 additions and 1213 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTP_PORTS=10000 INVENTAR_HTTP_PORTS=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
INVENTAR_APP_CPUS=1.0 INVENTAR_APP_CPUS=1.0
INVENTAR_APP_MEM_LIMIT=384m INVENTAR_APP_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m INVENTAR_APP_MEM_SWAP_LIMIT=768m
+1 -3
View File
@@ -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"
+141 -65
View File
@@ -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"
fi
fi
repo = os.environ["REPO"] if [[ "$latest_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
token = os.environ.get("GH_TOKEN", "") major=${BASH_REMATCH[1]}
bump = os.environ.get("BUMP_TYPE", "patch").strip().lower() minor=${BASH_REMATCH[2]}
patch=${BASH_REMATCH[3]}
else
major=3; minor=0; patch=0
fi
req = urllib.request.Request( # Bump strategy: major / minor / patch
f"https://api.github.com/repos/{repo}/releases/latest", if [ "${BUMP_TYPE:-}" = "major" ]; then
headers={ major=$((major + 1)); minor=0; patch=0
"Authorization": f"Bearer {token}", elif [ "${BUMP_TYPE:-}" = "minor" ]; then
"Accept": "application/vnd.github+json", minor=$((minor + 1)); patch=0
"User-Agent": "legendary-octo-garbanzo-release-workflow", else
}, patch=$((patch + 1))
) fi
latest_tag = "v3.0.0" if [ "${BUMP_TYPE:-}" = "development" ]; then
try: TAG="v${major}.${minor}.${patch}-dev"
with urllib.request.urlopen(req, timeout=20) as resp: else
data = json.loads(resp.read().decode("utf-8")) TAG="v${major}.${minor}.${patch}"
latest_tag = (data.get("tag_name") or latest_tag).strip() fi
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 ! 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)" 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
done i="$((i + 1))"
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
View File
@@ -0,0 +1 @@
v0.7.78
+38
View File
@@ -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 UpdateFlows genutzt werden.
- Es gibt pro Release genau einen ReleaseEintrag (für DevReleases mit `-dev` Suffix). Es wird kein separates `inventarsystem-image-dev.tar.gz` mehr erzeugt; das Update/Deployment erfolgt über den ReleaseTag / ImageTag.
- `update.sh` unterstützt weiterhin `dev`/`development`-Modus und akzeptiert nun auch explizite ReleaseTags 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.
+1
View File
@@ -0,0 +1 @@
# Web package initialization
+276 -48
View File
@@ -1,11 +1,3 @@
'''
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
'''
""" """
Inventarsystem - Flask Web Application Inventarsystem - Flask Web Application
@@ -25,15 +17,29 @@ Features:
- Booking and reservation of items - Booking and reservation of items
""" """
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file 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
import user as us from werkzeug.exceptions import HTTPException
import items as it from werkzeug.routing import BuildError
import ausleihung as au from jinja2 import TemplateNotFound
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.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
@@ -41,7 +47,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
@@ -62,13 +67,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,
@@ -76,9 +80,9 @@ 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, get_tenant_trial_status, purge_expired_trial_tenants
app = Flask(__name__, static_folder='static') # Correctly set static folder app = Flask(__name__, static_folder='static') # Correctly set static folder
@@ -108,7 +112,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'])
@@ -165,7 +169,7 @@ SCHEDULER_INTERVAL = cfg.SCHEDULER_INTERVAL_MIN
SSL_CERT = cfg.SSL_CERT SSL_CERT = cfg.SSL_CERT
SSL_KEY = cfg.SSL_KEY SSL_KEY = cfg.SSL_KEY
LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media'] LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media', 'schulbuch']
INVOICE_CURRENCY = 'EUR' INVOICE_CURRENCY = 'EUR'
NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8'))) NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8')))
@@ -350,10 +354,58 @@ def _enforce_active_session_user():
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error') flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
@app.before_request
def _enforce_trial_tenant_expiry():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
if endpoint in {'login', 'logout', 'impressum', 'license'}:
return None
try:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx else session.get('tenant_id')
if not tenant_id:
return None
trial_status = get_tenant_trial_status(tenant_id)
if not trial_status.get('enabled'):
return None
session['trial_status'] = {
'enabled': True,
'expired': bool(trial_status.get('expired')),
'days_left': trial_status.get('days_left'),
'expires_at': trial_status.get('expires_at').isoformat() if trial_status.get('expires_at') else None,
}
if not trial_status.get('expired'):
return None
if request.path.startswith('/api/') or request.is_json:
return jsonify({
'ok': False,
'message': 'Diese Testinstanz ist abgelaufen und wurde deaktiviert.',
'expired': True,
}), 410
session.pop('username', None)
session.pop('admin', None)
session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
flash('Diese Testinstanz ist abgelaufen und wurde deaktiviert.', 'error')
return redirect(url_for('login'))
except Exception as exc:
app.logger.warning(f'Trial expiry check failed: {exc}')
return None
@app.before_request @app.before_request
def _enforce_module_access(): def _enforce_module_access():
endpoint = request.endpoint or '' endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static') or request.path.startswith('/api/'): if endpoint == 'static' or endpoint.startswith('static'):
return None return None
for name, matcher in cfg.MODULES._path_matchers.items(): for name, matcher in cfg.MODULES._path_matchers.items():
@@ -363,7 +415,18 @@ def _enforce_module_access():
'inventory': "Inventar-Modul ist deaktiviert.", 'inventory': "Inventar-Modul ist deaktiviert.",
'student_cards': "Schülerausweis-Modul ist deaktiviert." 'student_cards': "Schülerausweis-Modul ist deaktiviert."
}.get(name, f"{name.capitalize()}-Modul ist deaktiviert.") }.get(name, f"{name.capitalize()}-Modul ist deaktiviert.")
return msg, 403
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': msg}), 403
flash(msg, 'info')
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('/library')
return redirect(url_for('my_borrowed_items'))
""" -----------------------------------------------------------After Request Handlers----------------------------------------------------------------------------- """ """ -----------------------------------------------------------After Request Handlers----------------------------------------------------------------------------- """
@@ -401,6 +464,61 @@ def _set_security_headers(response):
return response return response
@app.errorhandler(TemplateNotFound)
def handle_template_not_found(e):
"""Handle missing template files with a 404 response."""
app.logger.error(f"Template not found: {e.name}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Template not found', 'status': 404}), 404
return abort(404)
@app.errorhandler(404)
def handle_not_found(e):
"""Handle 404 errors with a user-friendly response."""
app.logger.warning(f"404 Not Found: {request.path}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Not found', 'status': 404}), 404
if 'username' in session:
return render_template('error.html', error_code=404, error_message='Seite nicht gefunden.'), 404
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
@@ -1038,6 +1156,7 @@ def inject_version():
'permission_action_options': PERMISSION_ACTION_OPTIONS, 'permission_action_options': PERMISSION_ACTION_OPTIONS,
'permission_page_options': PERMISSION_PAGE_OPTIONS, 'permission_page_options': PERMISSION_PAGE_OPTIONS,
'permission_presets': us.get_permission_preset_definitions(), 'permission_presets': us.get_permission_preset_definitions(),
'trial_status': session.get('trial_status', {}),
} }
@@ -1054,6 +1173,16 @@ def create_daily_backup():
except Exception as e: except Exception as e:
app.logger.error(f"Daily backup creation failed: {e}") app.logger.error(f"Daily backup creation failed: {e}")
def cleanup_expired_trial_tenants():
"""Remove expired trial tenants from config and MongoDB."""
try:
purged_tenants = purge_expired_trial_tenants()
if purged_tenants:
app.logger.warning(f"Purged expired trial tenants: {', '.join(purged_tenants)}")
except Exception as exc:
app.logger.error(f"Trial tenant cleanup failed: {exc}")
def update_appointment_statuses(): def update_appointment_statuses():
""" """
Aktualisiert automatisch die Status aller Terminplaner-Einträge. Aktualisiert automatisch die Status aller Terminplaner-Einträge.
@@ -1249,6 +1378,7 @@ def _initialize_scheduler():
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS) scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN) scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN) scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=cleanup_expired_trial_tenants, trigger="interval", hours=1)
scheduler.start() scheduler.start()
_scheduler_initialized = True _scheduler_initialized = True
app.logger.info(f"Scheduler started successfully (interval={cfg.SCHEDULER_INTERVAL_MIN} min)") app.logger.info(f"Scheduler started successfully (interval={cfg.SCHEDULER_INTERVAL_MIN} min)")
@@ -2337,11 +2467,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)
@@ -2608,7 +2734,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
@@ -2619,7 +2745,8 @@ def home():
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
open_item=request.args.get('open_item')
) )
else: else:
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user') permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
@@ -2662,6 +2789,7 @@ def home_admin():
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
school_info=_get_school_info_for_export(), school_info=_get_school_info_for_export(),
open_item=request.args.get('open_item')
) )
@@ -2682,6 +2810,49 @@ def tutorial_page():
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
) )
@app.route('/library/export', defaults={'scope': 'all'})
@app.route('/library/export/<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 Web.modules.inventarsystem.excel_export as excel_export
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items_collection = db['items']
query = {'ItemType': {'$in': LIBRARY_ITEM_TYPES}, 'Deleted': {'$ne': True}}
if scope == 'borrowed_by_me':
query['User'] = username
query['Verfuegbar'] = False
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
elif scope == 'all_borrowed':
if not is_admin_user:
return redirect('/library')
query['Verfuegbar'] = False
filename = "Bibliothek_Alle_Ausleihen.xlsx"
elif scope == 'schulbuecher':
query['ItemType'] = 'schulbuch'
filename = "Schulbuecher_Bestand.xlsx"
else:
# 'all'
filename = "Bibliothek_Gesamtbestand.xlsx"
items = list(items_collection.find(query))
client.close()
excel_file = excel_export.generate_library_excel(items)
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
@app.route('/library') @app.route('/library')
def library_view(): def library_view():
""" """
@@ -4058,6 +4229,7 @@ def logout():
session.pop('is_admin', None) session.pop('is_admin', None)
session.pop('favorites', None) session.pop('favorites', None)
session.pop('favorites_owner', None) session.pop('favorites_owner', None)
session.pop('tenant_id', None)
return redirect(url_for('login')) return redirect(url_for('login'))
@@ -4667,16 +4839,26 @@ def upload_item():
# Check if base code is unique for single-item uploads # Check if base code is unique for single-item uploads
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]): if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]):
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.' existing_item = it._get_items_collection().find_one({"code_4": code_4[0]})
if is_mobile: if existing_item:
return jsonify({'success': False, 'message': error_msg}), 400 error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
if is_mobile:
return jsonify({
'success': False,
'message': error_msg,
'existing_item_id': str(existing_item['_id']),
'redirect_to_item': True
}), 400
flash(error_msg, 'info')
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
else: else:
conflicting_items = it.get_item_by_code_4(code_4[0]) error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
if conflicting_items: if is_mobile:
flash('Der Code wird bereits verwendet. Das existierende Element wurde geöffnet.', 'info') return jsonify({'success': False, 'message': error_msg}), 400
return redirect(url_for(success_redirect_endpoint, open_item_modal=str(conflicting_items[0]['_id']))) else:
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
# Validate optional per-item codes # Validate optional per-item codes
if individual_codes: if individual_codes:
@@ -4699,10 +4881,6 @@ def upload_item():
error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.' error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.'
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
conflicting_items = it.get_item_by_code_4(specific_code)
if conflicting_items:
flash(f'Der Einzelcode "{specific_code}" wird bereits verwendet. Das existierende Element wurde geöffnet.', 'info')
return redirect(url_for(success_redirect_endpoint, open_item_modal=str(conflicting_items[0]['_id'])))
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
@@ -5834,10 +6012,6 @@ def edit_item(id):
# Check if code is unique (excluding the current item) # Check if code is unique (excluding the current item)
if code_4 and not it.is_code_unique(code_4, exclude_id=id): if code_4 and not it.is_code_unique(code_4, exclude_id=id):
conflicting_items = it.get_item_by_code_4(code_4)
if conflicting_items:
flash('Der Code wird bereits verwendet. Das existierende Element wurde stattdessen geöffnet.', 'info')
return redirect(url_for('home_admin', open_item_modal=str(conflicting_items[0]['_id'])))
flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error') flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error')
return redirect(url_for('home_admin')) return redirect(url_for('home_admin'))
@@ -8551,11 +8725,17 @@ def manage_filters():
# Get predefined filter values # Get predefined filter values
filter1_values = it.get_predefined_filter_values(1) filter1_values = it.get_predefined_filter_values(1)
filter2_values = it.get_predefined_filter_values(2) filter2_values = it.get_predefined_filter_values(2)
filter3_values = it.get_predefined_filter_values(3)
# Get custom filter names
filter_names = it.get_filter_names()
return render_template( return render_template(
'manage_filters.html', 'manage_filters.html',
filter1_values=filter1_values, filter1_values=filter1_values,
filter2_values=filter2_values, filter2_values=filter2_values,
filter3_values=filter3_values,
filter_names=filter_names,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards') student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
) )
@@ -8619,6 +8799,41 @@ def remove_filter_value(filter_num, value):
return redirect(url_for('manage_filters')) return redirect(url_for('manage_filters'))
@app.route('/edit_filter_value/<int:filter_num>/<string:old_value>', methods=['POST'])
def edit_filter_value(filter_num, old_value):
"""
Edit a predefined value from the specified filter.
Args:
filter_num (int): Filter number (1, 2 or 3)
old_value (str): Value to edit
Returns:
flask.Response: Redirect to filter management page
"""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'success': False, 'error': 'Not authorized'}), 403
new_value = sanitize_form_value(request.form.get('new_value'))
if not new_value:
flash('Bitte geben Sie einen neuen Wert ein', 'error')
return redirect(url_for('manage_filters'))
if new_value == FILTER_SELECT_ALL_TOKEN:
flash('Dieser Wert ist reserviert und kann nicht als Filterwert gespeichert werden.', 'error')
return redirect(url_for('manage_filters'))
# Update the value from the filter
success = it.edit_predefined_filter_value(filter_num, old_value, new_value)
if success:
flash(f'Wert "{old_value}" wurde in "{new_value}" bei Filter {filter_num} geändert', 'success')
else:
flash(f'Fehler beim Ändern des Wertes oder Wert existiert bereits in Filter {filter_num}', 'error')
return redirect(url_for('manage_filters'))
@app.route('/get_predefined_filter_values/<int:filter_num>') @app.route('/get_predefined_filter_values/<int:filter_num>')
def get_predefined_filter_values(filter_num): def get_predefined_filter_values(filter_num):
""" """
@@ -9585,14 +9800,27 @@ def admin_school_settings():
try: try:
updated_school = cfg.update_school_info(school_info) updated_school = cfg.update_school_info(school_info)
current_school = updated_school current_school = updated_school
# Also process filter names
filter_name_1 = request.form.get('filter_name_1')
if filter_name_1: it.set_filter_name(1, filter_name_1.strip())
filter_name_2 = request.form.get('filter_name_2')
if filter_name_2: it.set_filter_name(2, filter_name_2.strip())
filter_name_3 = request.form.get('filter_name_3')
if filter_name_3: it.set_filter_name(3, filter_name_3.strip())
flash('Schulstammdaten wurden erfolgreich gespeichert.', 'success') flash('Schulstammdaten wurden erfolgreich gespeichert.', 'success')
except Exception as exc: except Exception as exc:
app.logger.error(f'Could not update school settings: {exc}\n{traceback.format_exc()}') app.logger.error(f'Could not update school settings: {exc}\n{traceback.format_exc()}')
flash('Die Schulstammdaten konnten nicht gespeichert werden.', 'error') flash('Die Schulstammdaten konnten nicht gespeichert werden.', 'error')
# Get current filter names to display in form
filter_names = it.get_filter_names()
return render_template( return render_template(
'admin_school_settings.html', 'admin_school_settings.html',
school_info=current_school, school_info=current_school,
filter_names=filter_names,
tenant_id=tenant_id, tenant_id=tenant_id,
tenant_db=tenant_db, tenant_db=tenant_db,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
@@ -9972,7 +10200,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)
+1
View File
@@ -0,0 +1 @@
# Web.modules package initialization
+1
View File
@@ -0,0 +1 @@
# Web.modules.bibliothek package initialization
+4
View File
@@ -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,
+88 -5
View File
@@ -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')
@@ -570,7 +570,10 @@ def get_primary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(1)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving primary filters: {e}") print(f"Error retrieving primary filters: {e}")
return [] return []
@@ -589,7 +592,10 @@ def get_secondary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(2)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving secondary filters: {e}") print(f"Error retrieving secondary filters: {e}")
return [] return []
@@ -608,7 +614,10 @@ def get_tertiary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(3)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving tertiary filters: {e}") print(f"Error retrieving tertiary filters: {e}")
return [] return []
@@ -785,6 +794,80 @@ def remove_predefined_filter_value(filter_num, value):
return result.modified_count > 0 return result.modified_count > 0
def edit_predefined_filter_value(filter_num, old_value, new_value):
"""
Edit a predefined value from a filter and update all matching items.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
old_value (str): Value to replace
new_value (str): New value
Returns:
bool: True if value was updated, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
filter_presets = db['filter_presets']
# Check if the new value already exists
existing = filter_presets.find_one({
'filter_num': filter_num,
'values': new_value
})
if existing and old_value != new_value:
client.close()
return False
# Update the value in the filter
result = filter_presets.update_one(
{'filter_num': filter_num, 'values': old_value},
{'$set': {'values.$': new_value}}
)
if result.modified_count > 0:
items = db['items']
filter_field = 'Filter' if filter_num == 1 else f'Filter{filter_num}'
# Also update all items that use this filter
items.update_many(
{filter_field: old_value},
{'$set': {filter_field: new_value}}
)
client.close()
return result.modified_count > 0
def get_filter_names():
"""Get customized filter category names."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names_doc = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names_doc and 'names' in names_doc:
return names_doc['names']
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
"""Set custom name for a filter category."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
# === LOCATION MANAGEMENT === # === LOCATION MANAGEMENT ===
def get_predefined_locations(): def get_predefined_locations():
@@ -100,9 +100,29 @@ DEFAULTS = {
} }
# Load configuration file # Load configuration file
CONFIG_PATH = os.path.join(BASE_DIR, '..', 'config.json') def _resolve_config_path():
"""Resolve runtime config path with robust fallbacks for Docker deployments."""
env_path = os.getenv('INVENTAR_CONFIG_PATH', '').strip()
if env_path:
return env_path
candidates = [
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(BASE_DIR))), 'config.json'),
os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'config.json'),
os.path.join(os.path.dirname(BASE_DIR), 'config.json'),
os.path.join(BASE_DIR, '..', 'config.json'),
]
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return os.path.abspath(candidates[0])
CONFIG_PATH = _resolve_config_path()
try: try:
with open(CONFIG_PATH, 'r') as f: with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
_conf = json.load(f) _conf = json.load(f)
except Exception: except Exception:
_conf = {} _conf = {}
@@ -134,8 +154,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 +231,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']))
+32
View File
@@ -0,0 +1,32 @@
from pymongo import MongoClient
import Web.modules.database.settings as cfg
def get_filter_names():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names:
return names.get('names', {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
})
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
+56 -26
View File
@@ -12,13 +12,14 @@ Provides methods for creating, validating, and retrieving user information.
''' '''
import hashlib import hashlib
import copy import copy
import importlib
import logging import logging
import re 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)
@@ -59,6 +60,35 @@ def _get_tenant_db(client):
return client[cfg.MONGODB_DB] return client[cfg.MONGODB_DB]
def _has_tenant_configs():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
tenant_registry = getattr(tenant_module, 'TENANT_REGISTRY', None)
if isinstance(tenant_registry, dict) and tenant_registry:
return True
except Exception:
continue
return isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict) and bool(cfg.TENANT_CONFIGS)
def _resolve_request_tenant_db():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
get_tenant_context = getattr(tenant_module, 'get_tenant_context', None)
if not callable(get_tenant_context):
continue
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
return ctx.db_name or ctx.resolve_tenant(), ctx.tenant_id
if ctx and ctx.db_name and not _has_tenant_configs():
return ctx.db_name, None
except Exception as exc:
logger.debug("Tenant context import %s failed: %s", module_name, exc)
return None, None
def build_name_synonym(first_name, last_name=''): def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias from 2 letters each.""" """Build a deterministic, non-personalized short alias from 2 letters each."""
first = _clean_name_fragment(first_name) first = _clean_name_fragment(first_name)
@@ -424,22 +454,26 @@ def check_nm_pwd(username, password):
Returns: Returns:
dict: User document if credentials are valid, None otherwise dict: User document if credentials are valid, None otherwise
""" """
db_name = cfg.MONGODB_DB db_name, tenant_id = _resolve_request_tenant_db()
tenant_db = None
ctx = None ctx = None
try: try:
from tenant import get_tenant_context from tenant import get_tenant_context
ctx = get_tenant_context() ctx = get_tenant_context()
if ctx and ctx.tenant_id: except Exception:
tenant_db = ctx.db_name or ctx.resolve_tenant() ctx = None
db_name = tenant_db
except Exception as exc: if not db_name:
logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}") if _has_tenant_configs():
logger.warning(
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
)
return None
db_name = cfg.MONGODB_DB
logger.info( logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r", "check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username, username,
ctx.tenant_id if ctx else None, tenant_id,
db_name, db_name,
cfg.MONGODB_HOST, cfg.MONGODB_HOST,
cfg.MONGODB_PORT, cfg.MONGODB_PORT,
@@ -498,8 +532,6 @@ def check_nm_pwd(username, password):
finally: finally:
client.close() client.close()
return None
def add_user( def add_user(
username, username,
@@ -536,16 +568,12 @@ def add_user(
for key, value in page_permissions.items(): for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value) permission_defaults['pages'][str(key)] = bool(value)
alias_first = name if str(name or '').strip() else username
alias_last = last_name if str(last_name or '').strip() else ''
name_alias = build_name_synonym(alias_first, alias_last)
user_doc = { user_doc = {
'Username': username, 'Username': username,
'Password': hashing(password), 'Password': hashing(password),
'Admin': False, 'Admin': False,
'active_ausleihung': None, 'active_ausleihung': None,
'name': name_alias, 'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '', 'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student), 'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'], 'PermissionPreset': permission_defaults['preset'],
@@ -650,15 +678,17 @@ def get_user(username):
return users.find_one({'Username': username}) or users.find_one({'username': username}) return users.find_one({'Username': username}) or users.find_one({'username': username})
# Try current tenant first when available # Try current tenant first when available
try: tenant_db, tenant_id = _resolve_request_tenant_db()
from tenant import get_tenant_context if tenant_db:
ctx = get_tenant_context() user = find_in_db(tenant_db)
if ctx and ctx.db_name: if user:
user = find_in_db(ctx.db_name) return user
if user:
return user if _has_tenant_configs() and tenant_db is None:
except Exception: logger.warning(
pass "Refusing default DB fallback for user lookup because tenant configs exist and no tenant was resolved."
)
return None
# Fallback to default configured database # Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB) user = find_in_db(cfg.MONGODB_DB)
+1
View File
@@ -0,0 +1 @@
# Web.modules.emailservice package initialization
+6
View File
@@ -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::"
@@ -0,0 +1,56 @@
import io
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
def generate_library_excel(items):
wb = Workbook()
ws = wb.active
ws.title = "Export"
headers = [
"Code", "Titel", "Autor", "Typ", "ISBN/Code",
"Filter 1", "Filter 2", "Filter 3",
"Status", "Ausgeliehen von", "Rückgabe", "Kosten"
]
ws.append(headers)
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
for item in items:
status = "Verfuegbar" if str(item.get("Verfuegbar", "True")).lower() == "true" else "Ausgeliehen"
row = [
item.get("Code_4", ""),
item.get("Name", ""),
item.get("Author", ""),
item.get("ItemType", ""),
item.get("ISBN", ""),
item.get("Filter", ""),
item.get("Filter2", ""),
item.get("Filter3", ""),
status,
item.get("User", ""),
item.get("ReturnDate", ""),
item.get("Anschaffungskosten", "")
]
ws.append(row)
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
ws.column_dimensions[column].width = min(max_length + 2, 50)
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
return excel_buffer
@@ -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
+1
View File
@@ -0,0 +1 @@
# Web.modules.log package initialization
+1
View File
@@ -0,0 +1 @@
print("hello")
+2 -2
View File
@@ -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__)
+8 -8
View File
@@ -10,13 +10,13 @@
.planned-appointments-panel { .planned-appointments-panel {
margin: 15px 0; margin: 15px 0;
padding: 15px; padding: 15px;
background-color: #e1f5fe; background-color: var(--ui-surface-soft, #e1f5fe);
border-left: 4px solid #03a9f4; border-left: 4px solid #03a9f4;
border-radius: 4px; border-radius: 4px;
} }
.planned-appointments-panel h4 { .planned-appointments-panel h4 {
color: #0277bd; color: var(--ui-title, #0277bd);
margin-top: 0; margin-top: 0;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 1.1em; font-size: 1.1em;
@@ -30,9 +30,9 @@
.appointment-item { .appointment-item {
padding: 10px; padding: 10px;
background-color: #f9fdff; background-color: var(--ui-surface, #f9fdff);
border-radius: 4px; border-radius: 4px;
border: 1px solid #b3e5fc; border: 1px solid var(--ui-border, #b3e5fc);
} }
.appointment-header { .appointment-header {
@@ -44,7 +44,7 @@
.appointment-date { .appointment-date {
font-weight: bold; font-weight: bold;
color: #0288d1; color: var(--ui-title, #0288d1);
} }
.appointment-period { .appointment-period {
@@ -86,14 +86,14 @@
.appointment-user { .appointment-user {
margin-left: auto; margin-left: auto;
font-style: italic; font-style: italic;
color: #546e7a; color: var(--ui-text-muted, #546e7a);
} }
.appointment-notes { .appointment-notes {
font-size: 0.9em; font-size: 0.9em;
color: #455a64; color: var(--ui-text, #455a64);
margin-top: 5px; margin-top: 5px;
padding-top: 5px; padding-top: 5px;
border-top: 1px solid #e1f5fe; border-top: 1px solid var(--ui-border, #e1f5fe);
font-style: italic; font-style: italic;
} }
+116 -10
View File
@@ -22,16 +22,21 @@
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--ui-bg: #0f172a; /* Tiefes Blau-Theme (Dark Blue) */
--ui-bg-accent: #1e293b; --ui-bg: #0b1120;
--ui-bg-accent: #111827;
--ui-surface: #1e293b; --ui-surface: #1e293b;
--ui-surface-soft: #25314a; --ui-surface-soft: #334155;
--ui-border: #334155; --ui-border: #475569;
--ui-text: #f1f5f9; --ui-text: #f8fafc;
--ui-text-muted: #94a3b8; --ui-text-muted: #94a3b8;
--ui-title: #ffffff; --ui-title: #ffffff;
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4); --ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5); --ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
/* Abgerundetes Format im gesamten Dark Mode */
--ui-radius: 20px;
--ui-btn-radius: 99px; /* Pillenform für Buttons */
} }
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */ /* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
@@ -57,9 +62,17 @@ body {
:root[data-theme="dark"] input, :root[data-theme="dark"] input,
:root[data-theme="dark"] select, :root[data-theme="dark"] select,
:root[data-theme="dark"] textarea { :root[data-theme="dark"] textarea {
background-color: var(--ui-surface-soft) !important; background-color: var(--ui-bg) !important;
color: var(--ui-text) !important; color: var(--ui-text) !important;
border-color: var(--ui-border) !important; border: 1px solid var(--ui-border) !important;
border-radius: 12px !important;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3) !important;
}
:root[data-theme="dark"] input:focus,
:root[data-theme="dark"] select:focus,
:root[data-theme="dark"] textarea:focus {
border-color: #52a8ff !important;
box-shadow: 0 0 0 2px rgba(82, 168, 255, 0.2), inset 0 1px 2px rgba(0,0,0,0.3) !important;
} }
body { body {
@@ -295,7 +308,7 @@ select:focus {
/* Modal dialog styling */ /* Modal dialog styling */
.modal-dialog-white { .modal-dialog-white {
background: white; background: var(--ui-surface);
padding: 20px; padding: 20px;
border-radius: 5px; border-radius: 5px;
text-align: center; text-align: center;
@@ -389,6 +402,10 @@ body.table-view .item-card {
background: var(--ui-surface); background: var(--ui-surface);
box-shadow: none; box-shadow: none;
scroll-snap-align: none; scroll-snap-align: none;
min-height: 60px;
display: flex;
flex-direction: column;
justify-content: space-between;
} }
body.table-view .item-card .card-content { body.table-view .item-card .card-content {
@@ -896,3 +913,92 @@ html, body {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
} }
/* ========================================================= */
/* UNIVERSAL DARK MODE OVERRIDES FOR HARDCODED HTML STYLES */
/* ========================================================= */
:root[data-theme="dark"] .bg-white,
:root[data-theme="dark"] .bg-light,
:root[data-theme="dark"] [style*="background-color: #fff"],
:root[data-theme="dark"] [style*="background-color: #ffffff"],
:root[data-theme="dark"] [style*="background-color: #f8f9fa"],
:root[data-theme="dark"] [style*="background-color: #f5f5f5"],
:root[data-theme="dark"] [style*="background: #fff"],
:root[data-theme="dark"] [style*="background: #ffffff"],
:root[data-theme="dark"] [style*="background: #f8f9fa"],
:root[data-theme="dark"] [style*="background: #f5f5f5"],
:root[data-theme="dark"] [style*="background: white"],
:root[data-theme="dark"] [style*="background-color: white"],
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .dropdown-menu {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-dark,
:root[data-theme="dark"] .text-black,
:root[data-theme="dark"] [style*="color: #000"],
:root[data-theme="dark"] [style*="color: rgb(0, 0, 0)"],
:root[data-theme="dark"] [style*="color: black"],
:root[data-theme="dark"] [style*="color: #333"] {
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-muted {
color: var(--ui-text-muted) !important;
}
/* Fix table backgrounds that might have hardcoded light colors */
:root[data-theme="dark"] .table-light,
:root[data-theme="dark"] table.table,
:root[data-theme="dark"] table [style*="background: #fff"] {
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
}
/* Fix list items */
:root[data-theme="dark"] .list-group-item {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
/* Additional inline style coverage */
:root[data-theme="dark"] [style*="background: #eef4ff"],
:root[data-theme="dark"] [style*="background-color: #eef4ff"],
:root[data-theme="dark"] [style*="background: #e9ecef"],
:root[data-theme="dark"] [style*="background-color: #e9ecef"],
:root[data-theme="dark"] [style*="background: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #fefefe"],
:root[data-theme="dark"] [style*="background: #fffdfd"] {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .badge-light,
:root[data-theme="dark"] .bg-light {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border: 1px solid var(--ui-border);
}
/* Fix Bookmark/Favorite Button in Dark Mode */
:root[data-theme="dark"] .bookmark-btn {
background: var(--ui-surface-soft) !important;
border-color: #f59e0b !important;
color: #f59e0b !important;
}
:root[data-theme="dark"] .bookmark-btn:hover,
:root[data-theme="dark"] .bookmark-btn.active {
background: #f59e0b !important;
color: var(--ui-bg) !important;
}
:root[data-theme="dark"] .favorite-item {
outline: 2px solid #f59e0b !important;
}
+19
View File
@@ -71,6 +71,25 @@
<small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</small> <small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</small>
</div> </div>
<hr style="margin: 24px 0; border: 0; border-top: 1px solid #e5e7eb;">
<h3 style="font-size: 1.1rem; margin-bottom: 16px; color: #111827;">Globales Filter-Wording</h3>
<p style="font-size: 0.9rem; color: #6b7280; margin-bottom: 16px;">Hier kannst du die globalen Bezeichnungen für die drei Haupt-Kategorien (Filter) der Anwendung ändern.</p>
<div class="form-row">
<div class="form-group">
<label for="filter_name_1">Name Filter 1</label>
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie">
</div>
<div class="form-group">
<label for="filter_name_2">Name Filter 2</label>
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'System/Bereich') }}" placeholder="z. B. System/Bereich">
</div>
<div class="form-group">
<label for="filter_name_3">Name Filter 3</label>
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art">
</div>
</div>
<div class="actions-row"> <div class="actions-row">
<button type="submit" class="btn btn-primary">Schulstammdaten speichern</button> <button type="submit" class="btn btn-primary">Schulstammdaten speichern</button>
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a> <a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
+10 -11
View File
@@ -329,7 +329,7 @@
} }
/* iPad/Tablet responsive adjustments */ /* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) { @media (max-width: 768px) {
.navbar .container-fluid { .navbar .container-fluid {
gap: 6px; gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px); padding-left: max(env(safe-area-inset-left, 0), 10px);
@@ -1096,16 +1096,6 @@
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">Meine Ausleihen</a> <a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">Meine Ausleihen</a>
</li> </li>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Öffnen Sie das Tutorial, um das System Schritt für Schritt kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}" data-tutorial-tip="Hier können Sie neue Artikel ins Inventar einpflegen."> Hochladen</a>
</li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten"> <button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
@@ -1119,6 +1109,15 @@
Mehr Optionen Mehr Optionen
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
{% if current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li><a class="dropdown-item" href="{{ url_for('upload_admin') }}"> Hochladen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li> <li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %} {% if current_permissions.pages.get('manage_filters', True) %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}
{% if error_code == 404 %}
Seite nicht gefunden - Inventarsystem
{% else %}
Fehler - Inventarsystem
{% endif %}
{% endblock %}
{% block content %}
<div class="error-container" style="text-align: center; padding: 60px 20px; min-height: 70vh; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div style="max-width: 600px;">
<!-- Error Code Display -->
<div style="font-size: 120px; font-weight: bold; color: #ef4444; margin-bottom: 20px;">
{{ error_code }}
</div>
<!-- Error Title -->
<h1 style="font-size: 32px; margin: 20px 0; color: #1f2937;">
{% if error_code == 404 %}
Seite nicht gefunden
{% else %}
Ein Fehler ist aufgetreten
{% endif %}
</h1>
<!-- Error Message -->
<p style="font-size: 18px; color: #6b7280; margin: 20px 0 40px;">
{{ error_message or 'Es tut uns leid, aber die angeforderte Seite konnte nicht gefunden werden.' }}
</p>
<!-- Action Buttons -->
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<a href="{{ url_for('home') }}" class="button" style="background-color: #3b82f6; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zur Startseite
</a>
<a href="javascript:history.back()" class="button" style="background-color: #6b7280; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zurück
</a>
</div>
<!-- Additional Help -->
<div style="margin-top: 60px; padding: 20px; background-color: #f3f4f6; border-radius: 8px;">
<p style="color: #6b7280; font-size: 14px;">
Wenn Sie denken, dass dies ein Fehler ist, oder Sie Hilfe benötigen,
<a href="{{ url_for('impressum') }}" style="color: #3b82f6; text-decoration: none;">kontaktieren Sie den Administrator</a>.
</p>
</div>
</div>
</div>
<style>
.error-container {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.button:hover {
opacity: 0.9;
transform: translateY(-2px);
transition: all 0.2s ease;
}
</style>
{% endblock %}
+4 -4
View File
@@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded"> <div class="impressum-content p-4 shadow rounded" style="background: var(--ui-surface); color: var(--ui-text);">
<h1 class="mb-4 text-center">Impressum</h1> <h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p> <p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
@@ -20,7 +20,7 @@
<h3 class="mb-3">Kontaktinformationen</h3> <h3 class="mb-3">Kontaktinformationen</h3>
<address class="mb-0"> <address class="mb-0">
<p><strong>Name:</strong> Invario UG</p> <p><strong>Name:</strong> Invario UG</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p> <p><strong>Adresse:</strong> Am Sportplatz 10<br>83052 Bruckmühl<br>Deutschland</p>
</address> </address>
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p> <p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
</div> </div>
@@ -29,8 +29,8 @@
<h3 class="mb-3">Verantwortlich für den Inhalt</h3> <h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 18 Abs. 2 MStV)</p> <p>(nach § 18 Abs. 2 MStV)</p>
<p>Invario UG<br> <p>Invario UG<br>
Musterstraße 123<br> Am Sportplatz 10<br>
12345 Musterstadt<br> 83052 Bruckmühl<br>
Deutschland</p> Deutschland</p>
</div> </div>
+1 -1
View File
@@ -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>
+11
View File
@@ -477,6 +477,7 @@
<select id="filterType"> <select id="filterType">
<option value="">-- Alle Typen --</option> <option value="">-- Alle Typen --</option>
<option value="book">Buch</option> <option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="cd">CD</option> <option value="cd">CD</option>
<option value="dvd">DVD</option> <option value="dvd">DVD</option>
<option value="other">Sonstige</option> <option value="other">Sonstige</option>
@@ -495,6 +496,15 @@
<div class="filter-buttons"> <div class="filter-buttons">
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button> <button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button> <button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button>
<div style="flex-grow: 1;"></div>
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/schulbuecher'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Schulbücher Excel
</button>
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/all_borrowed'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Alle ausgeliehen Excel
</button>
</div> </div>
</div> </div>
@@ -1174,6 +1184,7 @@
<label for="editLibraryType">Medientyp</label> <label for="editLibraryType">Medientyp</label>
<select id="editLibraryType"> <select id="editLibraryType">
<option value="book">Buch</option> <option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="cd">CD</option> <option value="cd">CD</option>
<option value="dvd">DVD</option> <option value="dvd">DVD</option>
<option value="media">Sonstige Medien</option> <option value="media">Sonstige Medien</option>
+113 -43
View File
@@ -751,6 +751,7 @@
let mainItemsSentinel = null; let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null; let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false; let mainItemsScrollPrefetchBound = false;
let cardVirtualizationObserver = null;
function ensureMainItemsLoadingIndicator() { function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -759,11 +760,14 @@
if (!mainItemsLoadingIndicator) { if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div'); mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator'; mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator'; mainItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainItemsLoadingIndicator.innerHTML = ` mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`; `;
mainItemsLoadingIndicator.style.display = 'flex';
mainItemsLoadingIndicator.style.width = '100%';
} }
itemsContainer.appendChild(mainItemsLoadingIndicator); itemsContainer.appendChild(mainItemsLoadingIndicator);
@@ -1189,6 +1193,59 @@
mainItemsScrollPrefetchBound = true; mainItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
card.classList.remove('empty-frame');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
card.classList.add('empty-frame');
}
}
}
});
}, {
// Keep approx ~120 items rendered in DOM relative to scroll position.
// Assuming ~100-400px per item/row, this margin unloads elements beyond that range.
rootMargin: isMobileLayout ? '18000px 0px 18000px 0px' : '9000px 0px 9000px 0px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainItemsHasMore) return; if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => { mainItemsObserver = new IntersectionObserver(entries => {
@@ -2419,29 +2476,29 @@
// Add these functions right after the loadFilterState() function // Add these functions right after the loadFilterState() function
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return; return;
} }
@@ -3106,44 +3163,45 @@
border: 1px solid var(--ui-border); border: 1px solid var(--ui-border);
border-radius: var(--ui-radius); border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm); box-shadow: var(--ui-shadow-sm);
padding: 20px; padding: 15px;
width: 100%; width: 100%;
max-width: 340px; max-width: 340px;
flex: 1 1 300px; flex: 1 1 300px;
scroll-snap-align: center; scroll-snap-align: center;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
} content-visibility: auto;
.item-card:hover { contain-intrinsic-size: 300px 380px;
transform: translateY(-5px); }
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); .item-card:hover {
} transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.item-card h3 { .item-card h3 {
margin-top: 0; margin-top: 0;
} font-size: 1.15rem;
margin-bottom: 8px;
}
.item-card .description { .item-card .description {
max-height: 100px; max-height: 60px;
overflow-y: auto; overflow-y: auto;
} font-size: 0.9rem;
margin-bottom: 8px;
}
.item-card .image-container { .item-card .image-container {
position: relative; position: relative;
max-width: 300px; max-width: 300px;
margin: 10px 0; margin: 8px 0;
} }
.item-card .item-image { .item-card .item-image {
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 300px; max-height: 180px;
display: none;
object-fit: cover;
}
.item-card .item-image:first-child {
display: block; display: block;
} }
@@ -4614,6 +4672,18 @@ function openItemQuick(id){
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
<style> <style>
/* Admin/Main Content Container align */ /* Admin/Main Content Container align */
+117 -31
View File
@@ -395,7 +395,7 @@
border: 1px solid var(--ui-border); border: 1px solid var(--ui-border);
border-radius: var(--ui-radius); border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm); box-shadow: var(--ui-shadow-sm);
padding: 20px; padding: 15px;
width: 100%; width: 100%;
max-width: 340px; max-width: 340px;
flex: 1 1 300px; flex: 1 1 300px;
@@ -403,6 +403,8 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 380px;
} }
.item-card.bulk-selected { .item-card.bulk-selected {
@@ -418,17 +420,21 @@
.item-card h3 { .item-card h3 {
margin-top: 0; margin-top: 0;
font-size: 1.15rem;
margin-bottom: 8px;
} }
.item-card .description { .item-card .description {
max-height: 100px; max-height: 60px;
overflow-y: auto; overflow-y: auto;
font-size: 0.9rem;
margin-bottom: 8px;
} }
.item-card .image-container { .item-card .image-container {
position: relative; position: relative;
max-width: 300px; max-width: 300px;
margin: 10px 0; margin: 8px 0;
} }
.bulk-delete-row { .bulk-delete-row {
@@ -477,7 +483,7 @@
.item-card .item-image { .item-card .item-image {
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 300px; max-height: 180px;
display: none; display: none;
object-fit: cover; object-fit: cover;
} }
@@ -2738,6 +2744,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
let descSearchIds = null; // Set of matching IDs or null when disabled let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = ''; let currentUsername = '';
let allItems = []; // Cache for all items let allItems = []; // Cache for all items
let allFilter1Values = new Set();
let allFilter2Values = new Set();
let allFilter3Values = new Set();
let bulkDeleteSelection = new Set(); let bulkDeleteSelection = new Set();
let bulkDeleteDrawerOpen = false; let bulkDeleteDrawerOpen = false;
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }}; const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
@@ -2876,30 +2885,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return;
} }
// Filter items based on filter1 and filter2 selections // Filter items based on filter1 and filter2 selections
@@ -3252,18 +3260,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const openItem = urlParams.get('open_item_modal');
if (openItem) {
openEditModalFromServer(openItem);
}
// Load saved filters // Load saved filters
loadFilterState(); loadFilterState();
// Load predefined filter values for dropdowns // Load predefined filter values for dropdowns
loadPredefinedFilterValues(1); loadPredefinedFilterValues(1);
loadPredefinedFilterValues(2); loadPredefinedFilterValues(2);
loadPredefinedFilterValues(3);
// Set up edit form submission // Set up edit form submission
setupEditFormSubmission(); setupEditFormSubmission();
@@ -3288,11 +3291,24 @@ document.addEventListener('DOMContentLoaded', ()=>{
}); });
}); });
// Fetch user status to get current username // Fetch user status and global filters
fetch('/user_status') Promise.all([
.then(response => response.json()) fetch('/user_status').then(response => response.json()),
.then(data => { fetch('/get_filter').then(response => response.json())
currentUsername = data.username; ])
.then(([userData, filterData]) => {
currentUsername = userData.username;
if (filterData.filter1) {
allFilter1Values = new Set(filterData.filter1.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter2) {
allFilter2Values = new Set(filterData.filter2.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter3) {
allFilter3Values = new Set(filterData.filter3.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
// Now load items with username information // Now load items with username information
loadItems(); loadItems();
// Setup card images display after a short delay to ensure items are loaded // Setup card images display after a short delay to ensure items are loaded
@@ -3301,7 +3317,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}, 300); }, 300);
}) })
.catch(error => { .catch(error => {
console.error('Error fetching user status:', error); console.error('Error fetching user status or filters:', error);
loadItems(); // Load items anyway in case of error loadItems(); // Load items anyway in case of error
}); });
@@ -3362,6 +3378,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
let mainAdminItemsLoadingIndicator = null; let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false; let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0; let totalItemsInSystem = 0;
let cardVirtualizationObserver = null;
function ensureMainAdminItemsLoadingIndicator() { function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -3370,11 +3387,14 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (!mainAdminItemsLoadingIndicator) { if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div'); mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator'; mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator'; mainAdminItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainAdminItemsLoadingIndicator.innerHTML = ` mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`; `;
mainAdminItemsLoadingIndicator.style.display = 'flex';
mainAdminItemsLoadingIndicator.style.width = '100%';
} }
itemsContainer.appendChild(mainAdminItemsLoadingIndicator); itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
@@ -3701,10 +3721,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
}); });
// Populate filter options // Populate filter options using server-provided full filter lists when available.
populateFilterOptions('filter1-options', [...filter1Values].sort(), 1); populateFilterOptions('filter1-options', allFilter1Values.size ? [...allFilter1Values].sort() : [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2); populateFilterOptions('filter2-options', allFilter2Values.size ? [...allFilter2Values].sort() : [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3); populateFilterOptions('filter3-options', allFilter3Values.size ? [...allFilter3Values].sort() : [...filter3Values].sort(), 3);
if (!append) { if (!append) {
itemsContainer.scrollLeft = 0; itemsContainer.scrollLeft = 0;
@@ -3789,6 +3809,60 @@ document.addEventListener('DOMContentLoaded', ()=>{
mainAdminItemsScrollPrefetchBound = true; mainAdminItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
card.classList.remove('empty-frame');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container') ||
e.target.closest('.bulk-delete-toggle') || e.target.closest('label')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
card.classList.add('empty-frame');
}
}
}
});
}, {
// Keep approx ~120 items rendered in DOM relative to scroll position.
// Assuming ~100-400px per item/row, this margin unloads elements beyond that range.
rootMargin: isMobileLayout ? '18000px 0px 18000px 0px' : '9000px 0px 9000px 0px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainAdminItemsHasMore) return; if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => { mainAdminItemsObserver = new IntersectionObserver(entries => {
@@ -5722,4 +5796,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
+105 -22
View File
@@ -15,16 +15,16 @@
<h1 class="mb-4">Filterwerte verwalten</h1> <h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row"> <div class="row">
<!-- Filter 1: Unterrichtsfach --> <!-- Filter 1 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2> <h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Fach/Kategorie') }} (Filter 1)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neues Unterrichtsfach..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -33,13 +33,25 @@
{% if filter1_values %} {% if filter1_values %}
<div class="list-group"> <div class="list-group">
{% for value in filter1_values %} {% for value in filter1_values %}
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item">
{{ value }} <div class="d-flex justify-content-between align-items-center mb-2">
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline"> <span>{{ value }}</span>
<button type="submit" class="btn btn-sm btn-danger" <div>
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> <button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-1-{{ loop.index }}')">Bearbeiten</button>
Entfernen <form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
</button> <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">Abbrechen</button>
</div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
@@ -51,16 +63,16 @@
</div> </div>
</div> </div>
<!-- Filter 2: Jahrgangsstufe --> <!-- Filter 2 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2> <h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'System/Bereich') }} (Filter 2)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neue Jahrgangsstufe..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -69,13 +81,73 @@
{% if filter2_values %} {% if filter2_values %}
<div class="list-group"> <div class="list-group">
{% for value in filter2_values %} {% for value in filter2_values %}
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item">
{{ value }} <div class="d-flex justify-content-between align-items-center mb-2">
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline"> <span>{{ value }}</span>
<button type="submit" class="btn btn-sm btn-danger" <div>
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> <button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-2-{{ loop.index }}')">Bearbeiten</button>
Entfernen <form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
</button> <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
<!-- Filter 3 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">{{ filter_names.get('3', 'Typ/Art') }} (Filter 3)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=3) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter3_values %}
<div class="list-group">
{% for value in filter3_values %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-3-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=3, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-3-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=3, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-3-{{ loop.index }}')">Abbrechen</button>
</div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
@@ -97,4 +169,15 @@
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a> <a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div> </div>
</div> </div>
<script>
function toggleEdit(id) {
const el = document.getElementById(id);
if (el.style.display === 'none') {
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
</script>
{% endblock %} {% endblock %}
-1
View File
@@ -709,7 +709,6 @@
</style> </style>
<div class="upload-container"> <div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %} {% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
+373 -41
View File
@@ -7,20 +7,61 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
Each tenant can support up to 20+ users with isolated data and resource pools. Each tenant can support up to 20+ users with isolated data and resource pools.
""" """
from flask import request, g, has_request_context from flask import request, g, session, has_request_context
from functools import wraps from functools import wraps
import datetime
import json
import logging import logging
import os import os
import re import re
import settings as cfg import ipaddress
from settings import MongoClient import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_TENANT_REGISTRY_MTIME = None
def _load_tenant_registry_from_config():
registry = {}
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if isinstance(tenants, dict):
registry.update(tenants)
except Exception as exc:
logger.warning("Failed to load tenant registry from config: %s", exc)
elif isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
registry.update(cfg.TENANT_CONFIGS)
return registry
def _refresh_tenant_registry():
global TENANT_REGISTRY, _TENANT_REGISTRY_MTIME
config_path = getattr(cfg, 'CONFIG_PATH', None)
current_mtime = None
if config_path and os.path.isfile(config_path):
try:
current_mtime = os.path.getmtime(config_path)
except OSError:
current_mtime = None
if current_mtime == _TENANT_REGISTRY_MTIME:
return TENANT_REGISTRY
TENANT_REGISTRY.clear()
TENANT_REGISTRY.update(_load_tenant_registry_from_config())
_TENANT_REGISTRY_MTIME = current_mtime
return TENANT_REGISTRY
# Tenant registry: maps subdomain/tenant_id to database name # Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {} TENANT_REGISTRY = _load_tenant_registry_from_config()
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
def _get_nested_value(source, path, default=None): def _get_nested_value(source, path, default=None):
@@ -65,6 +106,27 @@ def _parse_port_from_host(host):
return host, None return host, None
def _first_host_token(value):
raw = str(value or '').strip()
if not raw:
return ''
return raw.split(',', 1)[0].strip().lower()
def _request_host_candidates():
candidates = []
for header_name in ('X-Forwarded-Host', 'X-Original-Host', 'Host'):
token = _first_host_token(request.headers.get(header_name, ''))
if token and token not in candidates:
candidates.append(token)
direct_host = _first_host_token(getattr(request, 'host', ''))
if direct_host and direct_host not in candidates:
candidates.append(direct_host)
return candidates
def _tenant_id_for_port(port): def _tenant_id_for_port(port):
"""Map a host port to a registered tenant ID via tenant configs or env overrides.""" """Map a host port to a registered tenant ID via tenant configs or env overrides."""
for tenant_id, config in TENANT_REGISTRY.items(): for tenant_id, config in TENANT_REGISTRY.items():
@@ -88,8 +150,37 @@ def _tenant_id_for_port(port):
return None return None
def _find_registered_tenant_id(candidate):
candidate = str(candidate or '').strip()
if not candidate:
return None
if candidate in TENANT_REGISTRY:
return candidate
lowered = candidate.lower()
for tenant_id in TENANT_REGISTRY:
if str(tenant_id).lower() == lowered:
return tenant_id
return None
def _is_ip_host(hostname):
hostname = str(hostname or '').strip()
if not hostname:
return False
try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
def get_tenant_config(tenant_id=None): def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default.""" """Return the registered config for a tenant, falling back to default."""
_refresh_tenant_registry()
if tenant_id is None: if tenant_id is None:
ctx = get_tenant_context() ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default' tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
@@ -113,6 +204,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,8 +266,189 @@ 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):
return bool(enabled) enabled = _get_nested_value(config, ['modules', candidate_name, 'enabled'], None)
if enabled is not None:
return bool(enabled)
return bool(default)
def _parse_datetime_value(value):
if value is None or value == '':
return None
if isinstance(value, datetime.datetime):
parsed = value
else:
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.datetime.fromisoformat(text.replace('Z', '+00:00'))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
def get_tenant_trial_config(tenant_id=None):
"""Return the optional trial/demo lifecycle settings for a tenant."""
config = get_tenant_config(tenant_id)
trial_config = _get_nested_value(config, ['trial'], {})
if not isinstance(trial_config, dict):
trial_config = {}
demo_config = _get_nested_value(config, ['demo'], {})
if isinstance(demo_config, dict):
merged = dict(demo_config)
merged.update(trial_config)
trial_config = merged
return trial_config
def get_tenant_trial_status(tenant_id=None, now=None):
"""Compute the current trial status for a tenant.
The config may define either an absolute "expires_at" timestamp or a
relative lifetime via "started_at"/"created_at" plus one of
"expires_after_days", "ttl_days", or "days".
"""
trial_config = get_tenant_trial_config(tenant_id)
now = now or datetime.datetime.now()
enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
if not enabled:
return {
'enabled': False,
'expired': False,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': None,
'expires_at': None,
'days_left': None,
}
started_at = _parse_datetime_value(
trial_config.get('started_at')
or trial_config.get('created_at')
or trial_config.get('activated_at')
)
expires_at = _parse_datetime_value(trial_config.get('expires_at'))
if expires_at is None:
duration_days = trial_config.get('expires_after_days')
if duration_days is None:
duration_days = trial_config.get('ttl_days')
if duration_days is None:
duration_days = trial_config.get('days')
try:
duration_days = int(duration_days) if duration_days is not None else None
except (TypeError, ValueError):
duration_days = None
if duration_days is not None:
base_time = started_at or now
expires_at = base_time + datetime.timedelta(days=max(0, duration_days))
expired = bool(expires_at and now >= expires_at)
days_left = None
if expires_at:
remaining = expires_at - now
days_left = max(0, int(remaining.total_seconds() // 86400))
return {
'enabled': True,
'expired': expired,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': started_at,
'expires_at': expires_at,
'days_left': days_left,
}
def _get_tenant_db_name_from_config(tenant_id):
config = get_tenant_config(tenant_id)
explicit_db = None
if isinstance(config, dict):
explicit_db = config.get('db') or config.get('db_name')
if explicit_db:
return _normalize_db_name(explicit_db)
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in str(tenant_id).lower())
return f'inventar_{sanitized}' if sanitized else cfg.MONGODB_DB
def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
"""Delete a tenant's runtime data and optionally remove its config entry."""
tenant_id = str(tenant_id or '').strip()
if not tenant_id:
return False
db_name = _get_tenant_db_name_from_config(tenant_id)
if drop_database:
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
client.drop_database(db_name)
finally:
client.close()
except Exception as exc:
logger.warning("Failed to drop tenant database %s for %s: %s", db_name, tenant_id, exc)
if remove_from_config:
try:
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if not isinstance(tenants, dict):
tenants = {}
aliases = set(_tenant_db_aliases(tenant_id))
aliases.add(tenant_id)
lowered = tenant_id.lower()
if lowered.startswith('schule'):
aliases.add('school' + lowered[len('schule'):])
elif lowered.startswith('school'):
aliases.add('schule' + lowered[len('school'):])
removed_any = False
for alias in aliases:
if alias in tenants:
tenants.pop(alias, None)
removed_any = True
if removed_any:
config['tenants'] = tenants
with open(config_path, 'w', encoding='utf-8') as handle:
json.dump(config, handle, indent=4, ensure_ascii=False)
for alias in [tenant_id, *_tenant_db_aliases(tenant_id)]:
TENANT_REGISTRY.pop(alias, None)
except Exception as exc:
logger.warning("Failed to remove tenant config for %s: %s", tenant_id, exc)
return True
def purge_expired_trial_tenants(now=None):
"""Delete expired trial tenants that opted into auto-delete."""
now = now or datetime.datetime.now()
purged_tenants = []
for tenant_id in list(TENANT_REGISTRY.keys()):
status = get_tenant_trial_status(tenant_id, now=now)
if status.get('enabled') and status.get('expired') and status.get('auto_delete'):
if delete_tenant(tenant_id):
purged_tenants.append(tenant_id)
return purged_tenants
class TenantContext: class TenantContext:
@@ -181,45 +475,83 @@ class TenantContext:
# Priority 1: X-Tenant-ID header (for testing/internal APIs) # Priority 1: X-Tenant-ID header (for testing/internal APIs)
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip() tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header: if tenant_from_header:
self.tenant_id = tenant_from_header matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
self.config = get_tenant_config(tenant_from_header) self.tenant_id = matched_tenant
return self._get_db_name(tenant_from_header) self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 2: Port-based tenant mapping # Priority 2: Port/host based tenant mapping
host = request.host.lower() host_candidates = _request_host_candidates()
_, port = _parse_port_from_host(host) primary_host = host_candidates[0] if host_candidates else _first_host_token(getattr(request, 'host', ''))
self.port = port logger.info(
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}") "Tenant resolution start: request.host=%s host_candidates=%s request.headers=%s",
if port: getattr(request, 'host', ''),
tenant_from_port = _tenant_id_for_port(port) host_candidates,
if tenant_from_port: dict(request.headers),
self.tenant_id = tenant_from_port )
self.config = get_tenant_config(tenant_from_port)
logger.info( for host in host_candidates:
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}" hostname, port = _parse_port_from_host(host)
) if port:
return self._get_db_name(tenant_from_port) self.port = port
logger.info(f"Tenant port not mapped: host={host} port={port}") tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction # Priority 3: Subdomain extraction
parts = host.split('.') for host in host_candidates:
host_without_port, _ = _parse_port_from_host(host)
host_without_port = (host_without_port or '').strip().lower()
if not host_without_port:
continue
# Extract subdomain from host direct_host_match = _find_registered_tenant_id(host_without_port)
# Examples: schule1.example.com → schule1 if direct_host_match:
# app.example.com → app (skip wildcard/app) self.subdomain = host_without_port
if len(parts) >= 3: self.tenant_id = direct_host_match
potential_subdomain = parts[0] self.config = get_tenant_config(direct_host_match)
session['tenant_id'] = direct_host_match
# Filter out common non-tenant subdomains
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
logger.info( logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}" f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
) )
return self._get_db_name(potential_subdomain) return self._get_db_name(direct_host_match)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
if host_without_port and not _is_ip_host(host_without_port):
parts = host_without_port.split('.')
if len(parts) >= 2:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
if session_tenant:
self.tenant_id = session_tenant
self.config = get_tenant_config(session_tenant)
logger.info(
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
)
return self._get_db_name(session_tenant)
# Fallback to default tenant if no tenant identifier found. # Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB. # If no explicit 'default' tenant config exists, use configured MongoDB DB.
Regular → Executable
View File
+8 -1
View File
@@ -95,6 +95,7 @@ services:
INVENTAR_MONGODB_HOST: mongodb INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017" INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: inventar_default INVENTAR_MONGODB_DB: inventar_default
INVENTAR_CONFIG_PATH: /app/config.json
# Redis Configuration (for sessions and caching) # Redis Configuration (for sessions and caching)
INVENTAR_REDIS_HOST: redis INVENTAR_REDIS_HOST: redis
@@ -144,7 +145,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
+47 -25
View File
@@ -1,41 +1,63 @@
version: "3.8"
services: services:
app: app:
build: . image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.39
container_name: inventory-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
environment: ports:
- MONGO_URL=mongodb://mongodb:27017/inventar - "10000:8000"
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
depends_on: depends_on:
- mongodb - mongodb
- redis - redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
mongodb: mongodb:
image: mongo:latest image: mongo:7.0
container_name: mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongo_data:/data/db - mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis: redis:
image: redis:alpine image: redis:7-alpine
container_name: redis container_name: inventarsystem-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
cloudflared: ports:
image: cloudflare/cloudflared:latest - "6379:6379"
container_name: cloudflared
restart: unless-stopped
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
volumes: volumes:
- ./config.yml:/etc/cloudflared/config.yml - redis_data:/data
- ./credentials.json:/etc/cloudflared/credentials.json healthcheck:
depends_on: test: ["CMD", "redis-cli", "ping"]
- app interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
mongo_data: mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
redis_data:
-1
View File
@@ -1 +0,0 @@
394722
+330 -21
View File
@@ -9,13 +9,54 @@ 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]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " add <tenant_id> [port] Add a new tenant (initializes database)" echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " trial <tenant_id> [port] [days] Create a 7-day demo tenant that auto-deletes after expiry"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)" echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)" echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)" echo " restart-all Restart all application containers (zero-downtime reload)"
@@ -25,6 +66,7 @@ show_help() {
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " ./manage-tenant.sh add school_a 10001" echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh trial school_demo 10002 7"
echo " ./manage-tenant.sh remove test_tenant" echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh module school_a inventory=off library=on" echo " ./manage-tenant.sh module school_a inventory=off library=on"
echo " ./manage-tenant.sh restart-all" echo " ./manage-tenant.sh restart-all"
@@ -32,6 +74,24 @@ show_help() {
exit 1 exit 1
} }
tenant_aliases() {
local tenant_id="$1"
local normalized alias
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
printf '%s\n' "$tenant_id"
if [[ "$normalized" == schule* ]]; then
alias="school${normalized#schule}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
elif [[ "$normalized" == school* ]]; then
alias="schule${normalized#school}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
fi
}
register_tenant_port() { register_tenant_port() {
local tenant_id="$1" local tenant_id="$1"
local port="$2" local port="$2"
@@ -47,15 +107,25 @@ with open(path, 'r', encoding='utf-8') as f:
tenants = cfg.get('tenants') tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict): if tenants is None or not isinstance(tenants, dict):
tenants = {} tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items(): for tid, conf in tenants.items():
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id: if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr) print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2) sys.exit(2)
existing = tenants.get(tenant_id) existing = {}
if existing is None or not isinstance(existing, dict): for alias in aliases:
existing = {} alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
existing['port'] = int(port_str) existing['port'] = int(port_str)
tenants[tenant_id] = existing for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f: with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False) json.dump(cfg, f, indent=4, ensure_ascii=False)
@@ -69,6 +139,176 @@ PY
fi fi
} }
write_trial_tenant_config() {
local tenant_id="$1"
local port="$2"
local trial_days="$3"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port" "$trial_days"
import json, sys, os, datetime
path, tenant_id, port_str, trial_days_str = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items():
if port_str and isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
try:
trial_days = max(1, int(trial_days_str))
except ValueError:
print(f"Error: trial days must be numeric, got {trial_days_str!r}", file=sys.stderr)
sys.exit(3)
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
trial_config = {
'enabled': True,
'auto_delete': True,
'days': trial_days,
'ttl_days': trial_days,
'expires_after_days': trial_days,
'started_at': now,
'created_at': now,
}
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
if port_str:
existing['port'] = int(port_str)
existing['modules'] = {
'inventory': {'enabled': True},
'library': {'enabled': True},
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
}
existing['trial'] = trial_config
for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Configured trial tenant {tenant_id} with {trial_days} day(s) and auto-delete enabled")
PY
then
echo "Trial tenant $tenant_id configured in config.json"
else
echo "Failed to configure trial tenant $tenant_id"
exit 1
fi
}
initialize_tenant_database() {
local tenant_id="$1"
local mode="$2"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then
echo "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant."
return 0
fi
docker exec "$APP_CONTAINER" python3 -c '
import sys, re, datetime, hashlib
sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web")
from Web.modules.database import settings
from pymongo import MongoClient
tenant_id = sys.argv[1].lower()
mode = sys.argv[2]
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
action_permissions = {
"can_borrow": True,
"can_insert": True,
"can_edit": True,
"can_delete": True,
"can_manage_users": True,
"can_manage_settings": True,
"can_view_logs": True,
}
page_permissions = {
"home": True,
"tutorial_page": True,
"my_borrowed_items": True,
"notifications_view": True,
"impressum": True,
"license": True,
"library_view": True,
"terminplan": True,
"home_admin": True,
"upload_admin": True,
"library_admin": True,
"admin_borrowings": True,
"library_loans_admin": True,
"admin_damaged_items": True,
"admin_audit_dashboard": True,
"logs": True,
"manage_filters": True,
"manage_locations": True,
}
if db.users.count_documents({"Username": "admin"}) == 0:
db.users.insert_one({
"Username": "admin",
"Password": hashed_pw,
"Admin": True,
"active_ausleihung": None,
"name": "Admin",
"last_name": "User",
"IsStudent": False,
"PermissionPreset": "full_access",
"ActionPermissions": action_permissions,
"PagePermissions": page_permissions,
})
if mode == "trial":
db.settings.update_one(
{"setting_type": "tenant_trial"},
{"$set": {
"setting_type": "tenant_trial",
"enabled": True,
"auto_delete": True,
"days": 7,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}},
upsert=True,
)
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
' "$tenant_id" "$mode"
}
update_runtime_ports() { update_runtime_ports() {
local new_port="$1" local new_port="$1"
local env_file="$PWD/.docker-build.env" local env_file="$PWD/.docker-build.env"
@@ -174,11 +414,15 @@ EOF
} }
restart_app_container() { restart_app_container() {
local env_file="$PWD/.docker-build.env" local workdir="$SCRIPT_DIR"
local env_file="$SCRIPT_DIR/.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
workdir="$HOST_WORKDIR"
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" ) compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" ) compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" )
@@ -188,9 +432,9 @@ restart_app_container() {
fi fi
else else
# Normal case: called directly from host # Normal case: called directly from host
compose_args+=( -f "$PWD/docker-compose-multitenant.yml" ) compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" ) compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
fi fi
if [ -f "$env_file" ]; then if [ -f "$env_file" ]; then
compose_args+=( --env-file "$env_file" ) compose_args+=( --env-file "$env_file" )
@@ -198,7 +442,7 @@ restart_app_container() {
fi fi
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it # Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
if [ -n "$COMPOSE_PROJECT_NAME" ]; then if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" ) compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
fi fi
@@ -219,9 +463,20 @@ if not os.path.isfile(path):
with open(path, 'r', encoding='utf-8') as f: with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f) cfg = json.load(f)
tenants = cfg.get('tenants', {}) tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict): if not isinstance(tenants, dict):
sys.exit(2) sys.exit(2)
removed = tenants.pop(tenant_id, None) aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
removed = None
for alias in list(aliases):
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
removed = alias_cfg if removed is None else removed
tenants.pop(alias, None)
cfg['tenants'] = tenants cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f: with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False) json.dump(cfg, f, indent=4, ensure_ascii=False)
@@ -333,7 +588,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}'
@@ -388,6 +643,36 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
echo "Data will be initialized upon first access by the tenant." echo "Data will be initialized upon first access by the tenant."
fi fi
;; ;;
trial)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
PORT_ARG="${3:-}"
DAYS_ARG="${4:-7}"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
fi
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Initializing trial database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "trial"
echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete."
;;
remove) remove)
if [ -z "$TENANT_ID" ]; then if [ -z "$TENANT_ID" ]; then
@@ -401,7 +686,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 +733,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 +780,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_'
@@ -535,11 +821,26 @@ with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f) cfg = json.load(f)
tenants = cfg.setdefault('tenants', {}) tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {}) aliases = {tenant_id}
if 'port' not in tenant_cfg: normalized = tenant_id.lower()
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr) if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
tenant_cfg = None
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
tenant_cfg = alias_cfg
break
if tenant_cfg is None:
tenant_cfg = {}
modules = tenant_cfg.setdefault('modules', {}) modules = tenant_cfg.setdefault('modules', {})
port = tenant_cfg.get('port')
if port is None:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
for arg in module_args: for arg in module_args:
if '=' not in arg: if '=' not in arg:
@@ -551,6 +852,14 @@ for arg in module_args:
module_cfg['enabled'] = state module_cfg['enabled'] = state
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.") print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
for alias in aliases:
alias_cfg = tenants.get(alias)
if not isinstance(alias_cfg, dict):
alias_cfg = {}
alias_cfg.update(tenant_cfg)
alias_cfg['modules'] = modules
tenants[alias] = alias_cfg
with open(path, 'w', encoding='utf-8') as f: with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False) json.dump(cfg, f, indent=4, ensure_ascii=False)
PY PY
-5
View File
@@ -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.
-588
View File
@@ -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'])
-5
View File
@@ -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])
+54
View File
@@ -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"
-235
View File
@@ -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()