Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 884464cfe5 | |||
| 0be4e3ca58 | |||
| e2aeea46f9 | |||
| 865fddd45b | |||
| 1835195d2f | |||
| 19a585b2ec | |||
| 925f07e96f | |||
| b8beec8209 | |||
| dd9390c649 | |||
| f31c4e2ff7 | |||
| 71e2895362 | |||
| d2c7e57f8d | |||
| 111d40b787 | |||
| 1a9513d442 | |||
| 3900059eb1 | |||
| 07b1cc5e79 | |||
| 6583f7a368 | |||
| 8e11fbf969 | |||
| 7d13dc264c | |||
| 480bd00a54 | |||
| fdf699363a | |||
| dd495a8b13 | |||
| ca57961d4b | |||
| 3923939387 | |||
| eb9c655398 | |||
| 4c2657218d | |||
| 875cfa01a3 | |||
| c0c61a41a3 | |||
| 9e74d0c16d | |||
| c8818edf0b | |||
| b270bc9367 | |||
| e1c284fc40 | |||
| debd4dceb1 | |||
| db15df57c3 | |||
| bcfe2095d5 | |||
| 69ac6eca63 | |||
| 717e3ce15e | |||
| 007ae04bf3 | |||
| c0947c5cf1 | |||
| c1d2935ad6 | |||
| 9fde0b4b2a | |||
| da7b075cc4 | |||
| 347f258b8f | |||
| 2421f867ed | |||
| 921915e92f | |||
| a4b0866293 | |||
| e10d86da4b | |||
| f160f23e9c | |||
| ca272542de | |||
| 7df9b65389 | |||
| e4af76c9c1 | |||
| f5944bf090 | |||
| 4e68da2368 | |||
| 0d6aca4e8c | |||
| 2bb0cbe599 | |||
| 339487b4d4 | |||
| 3f90e2d4f1 | |||
| b238b52fbf | |||
| 545dd7783c | |||
| f4ce1cf3b4 | |||
| 7bd12bddaa | |||
| b8e23b94d2 | |||
| 06b32b4a3d | |||
| d24bd6ba56 | |||
| d1f48cf184 | |||
| 32fccd17ec | |||
| 01447220a0 | |||
| b3aeb289ef | |||
| a6b90a6a9d | |||
| 0a5d7dedea | |||
| 74e11b5370 | |||
| c0d6ad7b72 | |||
| 7da7152332 | |||
| 3c9ab099ba | |||
| a81a8775a5 | |||
| 1633118d7f | |||
| a4b900c986 | |||
| a06393a915 | |||
| 1b9bfe1b14 | |||
| 3cdd1552d9 | |||
| 80a206f524 | |||
| 3217da8b15 | |||
| 22bc7c9c3c | |||
| f156cc178a | |||
| 116261d334 | |||
| 042afc19e2 | |||
| 28d049b52a | |||
| 8e478aeba4 | |||
| 1b38d1f0c8 | |||
| e7e24b3fae | |||
| fc80857fbe | |||
| f763ad064d | |||
| bc94ffdc18 | |||
| 76e9670cee | |||
| 577c6c4bed | |||
| 3aa19b10d1 | |||
| ff742d018e | |||
| 6fa40f26b0 | |||
| 777fc81064 | |||
| cf5e38e319 | |||
| b76941d5fe | |||
| f46f2674b0 | |||
| 8ebbdcfd54 | |||
| 92b5235035 | |||
| 4bda2e044b | |||
| b83b6c0ba2 | |||
| ab718d6ac2 | |||
| 112d9b6aa0 | |||
| 5139a50a43 | |||
| 997c7b42d5 | |||
| 20e5d3bb9a | |||
| f62b22c2f7 | |||
| e76d525e4b | |||
| 7117dac67c | |||
| 5313c507ed | |||
| 419fed4492 | |||
| c7112c7a42 | |||
| fb73c9d4e7 | |||
| 8414e27f25 | |||
| 5903f2afdd | |||
| a31fb4c048 | |||
| 46da45d373 | |||
| 0b1bcef985 | |||
| 99ad9d4f79 | |||
| 89c1a525d8 | |||
| 9701805552 | |||
| 4227934252 | |||
| d6883d1879 | |||
| f47e133c45 | |||
| dec3c16d7f | |||
| 897b2e43ad | |||
| 6dd44508ce | |||
| 204ded6c7c | |||
| fbd2168aff | |||
| 249a2bc2da | |||
| 7ab8f841d0 | |||
| 43c544244c | |||
| 73e4407d7b | |||
| 94fc01c08a | |||
| 788ec1db62 | |||
| 725584a104 | |||
| fbcc3b4522 | |||
| adc2bad3fb | |||
| f7be7d9bf0 | |||
| 1bd2db41b4 | |||
| ac68beb80a | |||
| ea6826b304 | |||
| d668e54042 | |||
| 3fd00aec30 | |||
| ef2d1dfab0 | |||
| 1c5aa9beed | |||
| a7d3863fde | |||
| ac0e60fadb | |||
| cbeed68147 | |||
| 715d932dba | |||
| 606e1822d1 | |||
| c0ce80d223 | |||
| 96debef4f4 | |||
| c6c57e4b53 | |||
| 3dc9ecfe5b | |||
| 8e20580552 |
+1
-2
@@ -8,5 +8,4 @@ INVENTAR_APP_MEM_SWAP_LIMIT=768m
|
|||||||
INVENTAR_WORKERS=4
|
INVENTAR_WORKERS=4
|
||||||
INVENTAR_THREADS=2
|
INVENTAR_THREADS=2
|
||||||
INVENTAR_WORKER_TIMEOUT=30
|
INVENTAR_WORKER_TIMEOUT=30
|
||||||
INVENTAR_WORKER_CONNECTIONS=100
|
INVENTAR_WORKER_CONNECTIONS=100
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
working_dir: /app/Web
|
||||||
|
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
|
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
|
||||||
build: null
|
build: null
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -15,15 +15,6 @@ on:
|
|||||||
- patch
|
- patch
|
||||||
- minor
|
- minor
|
||||||
- major
|
- 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
|
||||||
@@ -48,13 +39,12 @@ 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
|
||||||
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
|
||||||
latest_tag="v3.0.0"
|
latest_tag="v0.8.31"
|
||||||
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
|
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
|
||||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||||
if [ -n "$tag_name" ]; then
|
if [ -n "$tag_name" ]; then
|
||||||
@@ -67,7 +57,7 @@ jobs:
|
|||||||
minor=${BASH_REMATCH[2]}
|
minor=${BASH_REMATCH[2]}
|
||||||
patch=${BASH_REMATCH[3]}
|
patch=${BASH_REMATCH[3]}
|
||||||
else
|
else
|
||||||
major=3; minor=0; patch=0
|
major=0; minor=8; patch=31
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bump strategy: major / minor / patch
|
# Bump strategy: major / minor / patch
|
||||||
@@ -78,30 +68,28 @@ jobs:
|
|||||||
else
|
else
|
||||||
patch=$((patch + 1))
|
patch=$((patch + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
# Zusammenbau des Tags für den manuellen Run
|
||||||
TAG="v${major}.${minor}.${patch}-dev"
|
TAG="v${major}.${minor}.${patch}"
|
||||||
else
|
|
||||||
TAG="v${major}.${minor}.${patch}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Validierung des erzeugten oder übergebenen Tags
|
||||||
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[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 or vX.Y.Z-dev)"
|
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git fetch --tags --force
|
git fetch --tags --force
|
||||||
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
||||||
if [ -z "$LATEST_TAG" ]; then
|
if [ -z "$LATEST_TAG" ]; then
|
||||||
LATEST_TAG="v3.0.0"
|
LATEST_TAG="v0.8.31"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TAG_MAJOR="${TAG#v}"
|
TAG_MAJOR="${TAG#v}"
|
||||||
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
||||||
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
||||||
if [ -z "$LATEST_MAJOR" ]; then
|
if [ -z "$LATEST_MAJOR" ]; then
|
||||||
LATEST_MAJOR="3"
|
LATEST_MAJOR="0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If not explicitly bumping major, disallow changing major version
|
# If not explicitly bumping major, disallow changing major version
|
||||||
@@ -123,33 +111,6 @@ jobs:
|
|||||||
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
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
run: |
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
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 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
|
||||||
@@ -162,26 +123,16 @@ 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: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
|
||||||
tags: |
|
tags: |
|
||||||
${{ 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: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -204,30 +155,17 @@ jobs:
|
|||||||
docker build -t "$IMG" .
|
docker build -t "$IMG" .
|
||||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
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: |
|
||||||
mkdir -p release-bundle
|
mkdir -p release-bundle
|
||||||
cat > release-bundle/docker-compose.yml <<EOF
|
cat > release-bundle/docker-compose.yml <<EOF
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
||||||
container_name: inventarsystem-app
|
container_name: inventarsystem-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongodb
|
- mongodb
|
||||||
- redis
|
- redis
|
||||||
@@ -300,17 +238,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
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
|
# Make any shipped scripts executable
|
||||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
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 .
|
||||||
@@ -319,10 +246,8 @@ jobs:
|
|||||||
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
|
||||||
inventarsystem-image-dev.tar.gz
|
|
||||||
fail_on_unmatched_files: false
|
fail_on_unmatched_files: false
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.8.5
|
v0.8.31.1
|
||||||
|
|||||||
+1
-1
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
|
|||||||
WORKDIR /app/Web
|
WORKDIR /app/Web
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
|
|||||||
@@ -556,6 +556,14 @@ sudo chmod 644 certs/inventarsystem.crt
|
|||||||
|
|
||||||
## Fehlerbehebung
|
## Fehlerbehebung
|
||||||
|
|
||||||
|
### Logs Auslesen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs inventarsystem-app-1
|
||||||
|
docker exec inventarsystem-app-1 cat /data/logs/application.log | tail -n 100
|
||||||
|
docker compose exec mongodb mongosh
|
||||||
|
```
|
||||||
|
|
||||||
### Webserver startet nicht
|
### Webserver startet nicht
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+513
-149
@@ -25,6 +25,7 @@ from werkzeug.routing import BuildError
|
|||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
|
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
|
||||||
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -37,6 +38,7 @@ if _CURRENT_DIR not in sys.path:
|
|||||||
import Web.modules.database.user as us
|
import Web.modules.database.user as us
|
||||||
import Web.modules.database.items as it
|
import Web.modules.database.items as it
|
||||||
import Web.modules.database.ausleihung as au
|
import Web.modules.database.ausleihung as au
|
||||||
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.log.audit_log as al
|
import Web.modules.log.audit_log as al
|
||||||
import push_notifications as pn
|
import push_notifications as pn
|
||||||
import Web.modules.inventarsystem.pdf_export as pdf_export
|
import Web.modules.inventarsystem.pdf_export as pdf_export
|
||||||
@@ -84,7 +86,7 @@ from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
from tenant import get_tenant_context, get_tenant_trial_status, purge_expired_trial_tenants
|
from tenant import get_tenant_context, get_tenant_db, 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
|
||||||
@@ -626,14 +628,18 @@ def handle_unexpected_exception(e):
|
|||||||
if request.is_json or request.path.startswith('/api/'):
|
if request.is_json or request.path.startswith('/api/'):
|
||||||
return jsonify({'error': 'Internal server error', 'status': 500}), 500
|
return jsonify({'error': 'Internal server error', 'status': 500}), 500
|
||||||
|
|
||||||
if 'username' in session:
|
try:
|
||||||
|
return render_template(
|
||||||
|
'maintenance.html',
|
||||||
|
error_code=500,
|
||||||
|
error_message='Das System führt gerade Wartungsarbeiten durch.',
|
||||||
|
), 500
|
||||||
|
except Exception:
|
||||||
try:
|
try:
|
||||||
return render_template('error.html', error_code=500, error_message='Interner Serverfehler.'), 500
|
return render_template('error.html', error_code=500, error_message='Das System führt gerade Wartungsarbeiten durch.'), 500
|
||||||
except Exception:
|
except Exception:
|
||||||
return 'Internal Server Error', 500
|
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'}:
|
||||||
@@ -725,6 +731,7 @@ AUDIT_INDEXES_READY = False
|
|||||||
|
|
||||||
def _ensure_audit_indexes_once():
|
def _ensure_audit_indexes_once():
|
||||||
"""Ensure audit indexes exist once per process."""
|
"""Ensure audit indexes exist once per process."""
|
||||||
|
global AUDIT_INDEXES_READY
|
||||||
if AUDIT_INDEXES_READY:
|
if AUDIT_INDEXES_READY:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1263,7 +1270,8 @@ def inject_version():
|
|||||||
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
||||||
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
||||||
'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'),#
|
||||||
|
'mail_module_enabled': cfg.MODULES.is_enabled('mail'),
|
||||||
'is_admin': is_admin,
|
'is_admin': is_admin,
|
||||||
'unread_notification_count': unread_notification_count,
|
'unread_notification_count': unread_notification_count,
|
||||||
'current_permissions': current_permissions,
|
'current_permissions': current_permissions,
|
||||||
@@ -1729,7 +1737,6 @@ def is_valid_isbn13(isbn13):
|
|||||||
check_digit = (10 - (checksum % 10)) % 10
|
check_digit = (10 - (checksum % 10)) % 10
|
||||||
return check_digit == int(isbn13[12])
|
return check_digit == int(isbn13[12])
|
||||||
|
|
||||||
|
|
||||||
def normalize_and_validate_isbn(isbn_raw):
|
def normalize_and_validate_isbn(isbn_raw):
|
||||||
"""Normalize ISBN and return a valid canonical ISBN-13/10 or empty string."""
|
"""Normalize ISBN and return a valid canonical ISBN-13/10 or empty string."""
|
||||||
isbn = normalize_isbn(isbn_raw)
|
isbn = normalize_isbn(isbn_raw)
|
||||||
@@ -1989,7 +1996,7 @@ def _upload_student_cards_excel():
|
|||||||
synonyms = {
|
synonyms = {
|
||||||
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
|
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
|
||||||
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
|
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
|
||||||
'first_name': ['vorname', 'first_name', 'firstname'],
|
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'],
|
||||||
'last_name': ['nachname', 'last_name', 'lastname'],
|
'last_name': ['nachname', 'last_name', 'lastname'],
|
||||||
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
|
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
|
||||||
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
|
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
|
||||||
@@ -2880,6 +2887,7 @@ def home():
|
|||||||
username=session['username'],
|
username=session['username'],
|
||||||
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'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
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')
|
open_item=request.args.get('open_item')
|
||||||
@@ -2922,6 +2930,7 @@ def home_admin():
|
|||||||
username=session['username'],
|
username=session['username'],
|
||||||
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'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
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(),
|
||||||
@@ -2942,6 +2951,7 @@ def tutorial_page():
|
|||||||
is_admin=us.check_admin(session['username']),
|
is_admin=us.check_admin(session['username']),
|
||||||
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'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
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
|
||||||
)
|
)
|
||||||
@@ -3292,7 +3302,13 @@ def api_library_items():
|
|||||||
borrower = doc.get('User', '')
|
borrower = doc.get('User', '')
|
||||||
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
||||||
try:
|
try:
|
||||||
borrower = decrypt_text(borrower) if borrower else ''
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = client[cfg.MONGODB_DB]
|
||||||
|
student_cards = db['student_cards']
|
||||||
|
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
|
||||||
|
card = _decrypt_student_card_doc(card)
|
||||||
|
client.close()
|
||||||
|
borrower = card['SchülerName']
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback: keep original string if decryption fails
|
# Fallback: keep original string if decryption fails
|
||||||
borrower = borrower or ''
|
borrower = borrower or ''
|
||||||
@@ -3811,7 +3827,7 @@ def library_admin():
|
|||||||
show_library_features=True,
|
show_library_features=True,
|
||||||
upload_mode='library',
|
upload_mode='library',
|
||||||
page_title='Bücher hochladen',
|
page_title='Bücher hochladen',
|
||||||
back_target='home_admin'
|
back_target='library'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4114,10 +4130,13 @@ def student_card_barcode_download():
|
|||||||
c.setLineWidth(2)
|
c.setLineWidth(2)
|
||||||
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
||||||
|
|
||||||
|
|
||||||
|
school_name = cfg.get_school_info()
|
||||||
|
school_name = school_name["name"]
|
||||||
# "SCHÜLERAUSWEIS" text in header
|
# "SCHÜLERAUSWEIS" text in header
|
||||||
c.setFont("Helvetica-Bold", 9)
|
c.setFont("Helvetica-Bold", 9)
|
||||||
c.setFillColor(white)
|
c.setFillColor(white)
|
||||||
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS")
|
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
|
||||||
|
|
||||||
# Student name - large and bold
|
# Student name - large and bold
|
||||||
c.setFillColor(text_dark)
|
c.setFillColor(text_dark)
|
||||||
@@ -4141,7 +4160,11 @@ def student_card_barcode_download():
|
|||||||
c.setFillColor(text_dark)
|
c.setFillColor(text_dark)
|
||||||
c.setFont("Helvetica-Bold", 9)
|
c.setFont("Helvetica-Bold", 9)
|
||||||
c.drawString(x_pos + 3*mm, y_pos - 28*mm, card['Klasse'])
|
c.drawString(x_pos + 3*mm, y_pos - 28*mm, card['Klasse'])
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 31*mm, "-")
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 34*mm, "-")
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 37*mm, "-")
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 40*mm, "-")
|
||||||
|
|
||||||
# Right barcode section with border highlight
|
# Right barcode section with border highlight
|
||||||
barcode_x_start = x_pos + info_width + 1*mm
|
barcode_x_start = x_pos + info_width + 1*mm
|
||||||
c.setFillColor(accent_color)
|
c.setFillColor(accent_color)
|
||||||
@@ -4281,10 +4304,12 @@ def student_card_single_barcode_download(card_id):
|
|||||||
c.setLineWidth(2.5)
|
c.setLineWidth(2.5)
|
||||||
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
||||||
|
|
||||||
|
school_name = cfg.get_school_info()
|
||||||
|
school_name = school_name["name"]
|
||||||
# "SCHÜLERAUSWEIS" text in header
|
# "SCHÜLERAUSWEIS" text in header
|
||||||
c.setFont("Helvetica-Bold", 11)
|
c.setFont("Helvetica-Bold", 11)
|
||||||
c.setFillColor(white)
|
c.setFillColor(white)
|
||||||
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS")
|
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
|
||||||
|
|
||||||
# Student name - large and prominent
|
# Student name - large and prominent
|
||||||
c.setFillColor(text_dark)
|
c.setFillColor(text_dark)
|
||||||
@@ -4799,6 +4824,96 @@ def get_bookings():
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/get_user_appointments')
|
||||||
|
def get_user_appointments():
|
||||||
|
"""Return the current user's planned and active appointments for the calendar."""
|
||||||
|
if 'username' not in session:
|
||||||
|
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
|
||||||
|
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
username = session.get('username')
|
||||||
|
|
||||||
|
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||||
|
db = client[MONGODB_DB]
|
||||||
|
items_col = db['items']
|
||||||
|
|
||||||
|
# Use the appointment collection for the user's appointments
|
||||||
|
appointments = termin.get_upcoming_for_user(username, limit=250)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
def _date_iter(start_value: str, end_value: str):
|
||||||
|
try:
|
||||||
|
start_date = datetime.datetime.strptime(start_value, '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.datetime.strptime(end_value, '%Y-%m-%d').date()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if end_date < start_date:
|
||||||
|
end_date = start_date
|
||||||
|
cursor = start_date
|
||||||
|
days = []
|
||||||
|
while cursor <= end_date:
|
||||||
|
days.append(cursor.strftime('%Y-%m-%d'))
|
||||||
|
cursor += datetime.timedelta(days=1)
|
||||||
|
return days
|
||||||
|
|
||||||
|
for appt in appointments:
|
||||||
|
appt_id = str(appt.get('_id') or '')
|
||||||
|
if not appt_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_start = str(appt.get('date_start') or '')
|
||||||
|
date_end = str(appt.get('date_end') or date_start)
|
||||||
|
time_span = appt.get('time_span', []) or []
|
||||||
|
title = appt.get('note') or f"Termin von {appt.get('user') or ''}"
|
||||||
|
days_in_range = _date_iter(date_start, date_end) or ([date_start] if date_start else [])
|
||||||
|
|
||||||
|
span_entries = []
|
||||||
|
for entry in time_span:
|
||||||
|
s = str(entry or '').strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
m_date = _re.match(r"^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||||
|
if m_date:
|
||||||
|
span_entries.append((m_date.group(1), m_date.group(2), m_date.group(3)))
|
||||||
|
continue
|
||||||
|
m = _re.match(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||||
|
if m:
|
||||||
|
for day in days_in_range:
|
||||||
|
span_entries.append((day, m.group(1), m.group(2)))
|
||||||
|
|
||||||
|
# If the stored span is already day-specific, use it as-is.
|
||||||
|
# If it was generic, we duplicated it to each day in the range above.
|
||||||
|
if not span_entries and days_in_range:
|
||||||
|
# Fallback: create a simple all-day marker for each date so the appointment is visible.
|
||||||
|
for day in days_in_range:
|
||||||
|
span_entries.append((day, '08:00', '16:45'))
|
||||||
|
|
||||||
|
for day, start_time, end_time in span_entries:
|
||||||
|
result.append({
|
||||||
|
'id': f"{appt_id}-{day}-{start_time}",
|
||||||
|
'title': title,
|
||||||
|
'start': f"{day}T{start_time}",
|
||||||
|
'end': f"{day}T{end_time}",
|
||||||
|
'status': 'planned',
|
||||||
|
'itemId': appt_id,
|
||||||
|
'userName': str(appt.get('user') or ''),
|
||||||
|
'notes': str(appt.get('note') or ''),
|
||||||
|
'period': None,
|
||||||
|
'isCurrentUser': True,
|
||||||
|
'itemBorrower': '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'ok': True, 'bookings': result})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/booking_conflicts')
|
@app.route('/api/booking_conflicts')
|
||||||
def api_booking_conflicts():
|
def api_booking_conflicts():
|
||||||
"""
|
"""
|
||||||
@@ -4965,7 +5080,7 @@ def upload_item():
|
|||||||
Enhanced for mobile browser compatibility.
|
Enhanced for mobile browser compatibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
flask.Response: Redirect to admin homepage or JSON response
|
flask.Response: Redirect to admin homepage
|
||||||
"""
|
"""
|
||||||
# Check if the user is authenticated
|
# Check if the user is authenticated
|
||||||
if 'username' not in session:
|
if 'username' not in session:
|
||||||
@@ -4978,7 +5093,12 @@ def upload_item():
|
|||||||
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
|
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
|
||||||
|
|
||||||
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
|
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
|
||||||
success_redirect_endpoint = 'home_admin' if can_access_admin_home else 'home'
|
if can_access_admin_home:
|
||||||
|
success_redirect_endpoint = 'home_admin'
|
||||||
|
elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'):
|
||||||
|
success_redirect_endpoint = 'home_library'
|
||||||
|
else:
|
||||||
|
success_redirect_endpoint = 'home'
|
||||||
|
|
||||||
# Detect if request is from mobile device
|
# Detect if request is from mobile device
|
||||||
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
||||||
@@ -5007,6 +5127,7 @@ def upload_item():
|
|||||||
upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item'))
|
upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item'))
|
||||||
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
||||||
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
||||||
|
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item_count = int(item_count_raw) if item_count_raw else 1
|
item_count = int(item_count_raw) if item_count_raw else 1
|
||||||
@@ -5086,6 +5207,8 @@ def upload_item():
|
|||||||
item_isbn = isbn_raw
|
item_isbn = isbn_raw
|
||||||
if upload_mode == 'library':
|
if upload_mode == 'library':
|
||||||
item_type = 'book'
|
item_type = 'book'
|
||||||
|
if item_type_input != "":
|
||||||
|
item_type = item_type_input
|
||||||
|
|
||||||
if upload_mode == 'library':
|
if upload_mode == 'library':
|
||||||
if not cfg.MODULES.is_enabled('library'):
|
if not cfg.MODULES.is_enabled('library'):
|
||||||
@@ -5112,55 +5235,87 @@ def upload_item():
|
|||||||
flash(error_msg, 'error')
|
flash(error_msg, 'error')
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
return redirect(url_for(success_redirect_endpoint))
|
||||||
|
|
||||||
# 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]):
|
# 1. PARSE AND UNIFY CODES
|
||||||
existing_item = it._get_items_collection().find_one({"code_4": code_4[0]})
|
# -------------------------------------------------------------------
|
||||||
if existing_item:
|
# Assuming `code_4` might come in as a single string or a list depending on your form setup
|
||||||
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
|
primary_code_raw = request.form.get('code_4', '').strip()
|
||||||
if is_mobile:
|
individual_codes_raw = request.form.get('individual_codes', '').strip()
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
all_item_codes = []
|
||||||
'message': error_msg,
|
|
||||||
'existing_item_id': str(existing_item['_id']),
|
if primary_code_raw:
|
||||||
'redirect_to_item': True
|
all_item_codes.append(primary_code_raw)
|
||||||
}), 400
|
|
||||||
|
if individual_codes_raw:
|
||||||
flash(error_msg, 'info')
|
# Split textarea by newlines, clean whitespace, and ignore empty lines
|
||||||
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
|
extra_codes = [c.strip() for c in individual_codes_raw.replace('\r', '').split('\n') if c.strip()]
|
||||||
|
|
||||||
|
# Add to master list, ensuring no duplicates within the submitted list itself
|
||||||
|
for c in extra_codes:
|
||||||
|
if c not in all_item_codes:
|
||||||
|
all_item_codes.append(c)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 2. OVERRIDE ITEM COUNT
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# If the user scanned/generated codes, the amount of codes IS the item count.
|
||||||
|
# Otherwise, fallback to a manual item_count field if it exists.
|
||||||
|
if len(all_item_codes) > 0:
|
||||||
|
item_count = len(all_item_codes)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
item_count = int(request.form.get('item_count', 1))
|
||||||
|
except ValueError:
|
||||||
|
item_count = 1
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 3. IMAGE VALIDATION
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
|
||||||
|
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
|
||||||
|
if is_mobile:
|
||||||
|
return jsonify({'success': False, 'message': error_msg}), 400
|
||||||
else:
|
else:
|
||||||
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
|
flash(error_msg, 'error')
|
||||||
|
return redirect(url_for(success_redirect_endpoint))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 4. UNIFIED DB UNIQUENESS VALIDATION
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Validate every code in our master list against the database
|
||||||
|
for code in all_item_codes:
|
||||||
|
if not it.is_code_unique(code):
|
||||||
|
|
||||||
|
# Special case: If they only uploaded ONE item, redirect them to that existing item
|
||||||
|
if item_count == 1:
|
||||||
|
existing_item = it._get_items_collection().find_one({"code_4": code})
|
||||||
|
if existing_item:
|
||||||
|
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'])))
|
||||||
|
|
||||||
|
# If multiple items are being uploaded, just throw a standard error
|
||||||
|
error_msg = f'Der Code "{code}" wird bereits von einem anderen Artikel verwendet. Bitte überprüfen Sie Ihre Eingaben.'
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
return jsonify({'success': False, 'message': error_msg}), 400
|
||||||
else:
|
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
|
# -------------------------------------------------------------------
|
||||||
if individual_codes:
|
# 5. BATCH CODE GENERATOR (Remains mostly unchanged)
|
||||||
if len(individual_codes) > item_count:
|
# -------------------------------------------------------------------
|
||||||
error_msg = f'Zu viele Einzelcodes angegeben ({len(individual_codes)}), erlaubt sind maximal {item_count}.'
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
|
||||||
flash(error_msg, 'error')
|
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
|
||||||
|
|
||||||
if len(set(individual_codes)) != len(individual_codes):
|
|
||||||
error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.'
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
|
||||||
flash(error_msg, 'error')
|
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
|
||||||
|
|
||||||
for specific_code in individual_codes:
|
|
||||||
if not it.is_code_unique(specific_code):
|
|
||||||
error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.'
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
|
||||||
flash(error_msg, 'error')
|
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
|
||||||
|
|
||||||
def generate_unique_batch_code(base_code, position):
|
def generate_unique_batch_code(base_code, position):
|
||||||
"""Generate a unique code for every item in a batch."""
|
"""Generate a unique code for every item in a batch if no specific code is provided."""
|
||||||
if not base_code:
|
if not base_code:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -5900,22 +6055,9 @@ def upload_item():
|
|||||||
except Exception:
|
except Exception:
|
||||||
app.logger.warning('Audit write failed for library_item_created')
|
app.logger.warning('Audit write failed for library_item_created')
|
||||||
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({
|
flash(success_msg, 'success')
|
||||||
'success': True,
|
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
|
||||||
'message': success_msg,
|
|
||||||
'itemId': str(item_id),
|
|
||||||
'stats': {
|
|
||||||
'processed': processed_count,
|
|
||||||
'errors': error_count,
|
|
||||||
'skipped': skipped_count,
|
|
||||||
'duplicates': len(duplicate_images) if duplicate_images else 0,
|
|
||||||
'totalImages': len(image_filenames)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
flash(success_msg, 'success')
|
|
||||||
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
|
|
||||||
else:
|
else:
|
||||||
error_msg = 'Fehler beim Hinzufügen des Elements'
|
error_msg = 'Fehler beim Hinzufügen des Elements'
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
@@ -7853,7 +7995,8 @@ def admin_borrowings():
|
|||||||
'admin_borrowings.html',
|
'admin_borrowings.html',
|
||||||
entries=entries,
|
entries=entries,
|
||||||
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'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
|
|
||||||
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
|
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
|
||||||
@@ -7916,6 +8059,7 @@ def admin_audit_dashboard():
|
|||||||
audit_rows=audit_rows,
|
audit_rows=audit_rows,
|
||||||
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'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f"Error loading audit dashboard: {exc}")
|
app.logger.error(f"Error loading audit dashboard: {exc}")
|
||||||
@@ -8230,8 +8374,15 @@ def admin_create_invoice(borrow_id):
|
|||||||
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
|
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
|
||||||
return redirect(url_for('admin_borrowings'))
|
return redirect(url_for('admin_borrowings'))
|
||||||
|
|
||||||
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = client[cfg.MONGODB_DB]
|
||||||
|
student_cards = db['student_cards']
|
||||||
|
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
|
||||||
|
card = _decrypt_student_card_doc(card)
|
||||||
|
client.close()
|
||||||
|
borrower = card['SchülerName']
|
||||||
|
|
||||||
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
|
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
|
||||||
borrower = borrow_doc.get('User', '')
|
|
||||||
item_name = item_doc.get('Name', '')
|
item_name = item_doc.get('Name', '')
|
||||||
item_code = item_doc.get('Code_4', '')
|
item_code = item_doc.get('Code_4', '')
|
||||||
|
|
||||||
@@ -8731,6 +8882,7 @@ def library_item_invoices(item_id):
|
|||||||
invoices=entries,
|
invoices=entries,
|
||||||
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'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
|
app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
|
||||||
@@ -9281,17 +9433,265 @@ def search_word(word):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "response": str(e)})
|
return jsonify({"success": False, "response": str(e)})
|
||||||
|
|
||||||
|
def _fetch_from_google_books(clean_isbn):
|
||||||
|
"""Source 1: Google Books API (Free, No Key required for basic use)"""
|
||||||
|
try:
|
||||||
|
url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}"
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if data.get('totalItems', 0) > 0 and data.get('items'):
|
||||||
|
book_info = data['items'][0].get('volumeInfo', {})
|
||||||
|
sale_info = data['items'][0].get('saleInfo', {})
|
||||||
|
|
||||||
|
price = None
|
||||||
|
retail_price = sale_info.get('retailPrice', {})
|
||||||
|
list_price = sale_info.get('listPrice', {})
|
||||||
|
if retail_price and 'amount' in retail_price:
|
||||||
|
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}"
|
||||||
|
elif list_price and 'amount' in list_price:
|
||||||
|
price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}"
|
||||||
|
|
||||||
|
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
|
||||||
|
if thumbnail:
|
||||||
|
thumbnail = thumbnail.replace('http:', 'https:')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book_info.get('title', 'Unknown Title'),
|
||||||
|
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
|
||||||
|
"publisher": book_info.get('publisher', 'Unknown Publisher'),
|
||||||
|
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
|
||||||
|
"description": book_info.get('description', 'Keine Beschreibung verfügbar'),
|
||||||
|
"pageCount": book_info.get('pageCount', 'Unknown'),
|
||||||
|
"price": price,
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"source": "google-books"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Google Books error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_lobid_germany(clean_isbn):
|
||||||
|
"""
|
||||||
|
Source 2: Lobid.org (hbz Network)
|
||||||
|
The ultimate free, keyless fallback for German Schoolbooks and DACH literature.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"https://lobid.org/resources?q=isbn:{clean_isbn}&format=json"
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if 'member' in data and len(data['member']) > 0:
|
||||||
|
book = data['member'][0]
|
||||||
|
|
||||||
|
# Extract Authors (Lobid uses a nested contribution array)
|
||||||
|
authors = []
|
||||||
|
for contrib in book.get('contribution', []):
|
||||||
|
if 'agent' in contrib and 'label' in contrib['agent']:
|
||||||
|
authors.append(contrib['agent']['label'])
|
||||||
|
|
||||||
|
# Extract Publishers
|
||||||
|
publishers = []
|
||||||
|
for pub in book.get('publication', []):
|
||||||
|
if 'publishedBy' in pub:
|
||||||
|
# sometimes publishedBy is a list, sometimes a string
|
||||||
|
pub_data = pub['publishedBy']
|
||||||
|
if isinstance(pub_data, list):
|
||||||
|
publishers.extend(pub_data)
|
||||||
|
else:
|
||||||
|
publishers.append(pub_data)
|
||||||
|
|
||||||
|
# Extract Date
|
||||||
|
pub_date = 'Unknown Date'
|
||||||
|
if book.get('publication'):
|
||||||
|
pub_date = book['publication'][0].get('startDate', 'Unknown Date')
|
||||||
|
|
||||||
|
# Extract Pages
|
||||||
|
pages = book.get('extent', ['Unknown'])[0] if 'extent' in book else 'Unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book.get('title', 'Unknown Title'),
|
||||||
|
"authors": ', '.join(authors) if authors else 'Unknown Author',
|
||||||
|
"publisher": ', '.join(publishers) if publishers else 'Unknown Publisher',
|
||||||
|
"publishedDate": str(pub_date),
|
||||||
|
"description": 'Keine Beschreibung verfügbar (Schulbuch / Fachliteratur)',
|
||||||
|
"pageCount": pages,
|
||||||
|
"price": None,
|
||||||
|
"thumbnail": "", # Lobid rarely has covers, we will fallback to OpenLibrary cover below
|
||||||
|
"source": "lobid-germany"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Lobid error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_open_library(clean_isbn):
|
||||||
|
"""Source 3: Open Library Search API (Free, No Key required)"""
|
||||||
|
try:
|
||||||
|
url = f"https://openlibrary.org/search.json?isbn={clean_isbn}"
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if data.get('numFound', 0) > 0 and data.get('docs'):
|
||||||
|
book_info = data['docs'][0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book_info.get('title', 'Unknown Title'),
|
||||||
|
"authors": ', '.join(book_info.get('author_name', ['Unknown Author'])),
|
||||||
|
"publisher": ', '.join(book_info.get('publisher', ['Unknown Publisher'])),
|
||||||
|
"publishedDate": str(book_info.get('publish_date', ['Unknown Date'])[0]),
|
||||||
|
"description": 'Keine Beschreibung verfügbar',
|
||||||
|
"pageCount": book_info.get('number_of_pages_median', 'Unknown'),
|
||||||
|
"price": None,
|
||||||
|
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
|
||||||
|
"source": "openlibrary"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"OpenLibrary Search error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fetch_from_isbn_de(clean_isbn):
|
||||||
|
"""
|
||||||
|
Source 4: isbn.de (Maßgeschneidertes Scraping basierend auf realem HTML)
|
||||||
|
Extrahiert alle Buchdaten inklusive Preise aus den Meta-Tags und der Sidebar.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"https://www.isbn.de/buch/{clean_isbn}"
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
|
# 1. Titel aus Meta-Tags extrahieren (extrem zuverlässig)
|
||||||
|
title_meta = soup.find('meta', property='og:title')
|
||||||
|
title = title_meta.get('content', '').strip() if title_meta else None
|
||||||
|
if not title:
|
||||||
|
h1_elem = soup.find('h1')
|
||||||
|
title = h1_elem.text.strip() if h1_elem else "Unknown Title"
|
||||||
|
|
||||||
|
# Falls Fehler- oder Suchseite
|
||||||
|
if "nicht gefunden" in title.lower() or "suche" in title.lower():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Hilfsfunktion zum Parsen der .infotab-Sidebar-Struktur ---
|
||||||
|
def get_sidebar_value(keyword):
|
||||||
|
infotab = soup.find(class_='infotab')
|
||||||
|
if infotab:
|
||||||
|
for d in infotab.find_all('div'):
|
||||||
|
if d.text.strip().lower() == keyword.lower():
|
||||||
|
parent = d.parent
|
||||||
|
# Label vom Gesamttext abziehen, um nur den Wert zu erhalten
|
||||||
|
return parent.text.replace(d.text, '', 1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Verlag holen
|
||||||
|
publisher = get_sidebar_value('verlag')
|
||||||
|
if not publisher:
|
||||||
|
publisher = "Unknown Publisher"
|
||||||
|
|
||||||
|
# 3. Erscheinungsdatum (Aus Meta-Tag oder Sidebar)
|
||||||
|
date_meta = soup.find('meta', property='og:book:release_date')
|
||||||
|
if date_meta and date_meta.get('content'):
|
||||||
|
published_date = date_meta.get('content', '').strip()
|
||||||
|
else:
|
||||||
|
published_date = get_sidebar_value('erschienen am')
|
||||||
|
|
||||||
|
if not published_date:
|
||||||
|
published_date = "Unknown Date"
|
||||||
|
|
||||||
|
# 4. Autor (Da Schulbuch-Workbooks oft keinen Einzelautor haben, kluger Fallback)
|
||||||
|
author_meta = soup.find('meta', property='og:book:author')
|
||||||
|
author = author_meta.get('content', '').strip() if author_meta else ""
|
||||||
|
if not author:
|
||||||
|
author = get_sidebar_value('autor') or get_sidebar_value('herausgeber')
|
||||||
|
|
||||||
|
if not author:
|
||||||
|
author = f"{publisher} Redaktion" if publisher != "Unknown Publisher" else "Unknown Author"
|
||||||
|
|
||||||
|
# 5. Seitenanzahl
|
||||||
|
page_count = get_sidebar_value('seiten') or get_sidebar_value('umfang')
|
||||||
|
if page_count:
|
||||||
|
match = re.search(r'\d+', page_count)
|
||||||
|
page_count = match.group(0) if match else "Unknown"
|
||||||
|
else:
|
||||||
|
page_count = "Unknown"
|
||||||
|
|
||||||
|
# 6. Beschreibung aus ID 'bookdesc' holen und Whitespace-Fluss säubern
|
||||||
|
description = "Keine Beschreibung verfügbar"
|
||||||
|
desc_div = soup.find(id='bookdesc')
|
||||||
|
if desc_div:
|
||||||
|
# Holt den Text inklusive aller Listenpunkte (li) und formatiert ihn sauber
|
||||||
|
description = " ".join(desc_div.text.split())
|
||||||
|
|
||||||
|
# 7. Cover-Bild (Nutzt hochauflösenden Link aus den Metas)
|
||||||
|
thumbnail = ""
|
||||||
|
img_meta = soup.find('meta', property='og:image')
|
||||||
|
if img_meta:
|
||||||
|
thumbnail = img_meta.get('content', '').strip()
|
||||||
|
else:
|
||||||
|
img_tag = soup.find('img', id='ISBNcover')
|
||||||
|
if img_tag:
|
||||||
|
thumbnail = img_tag.get('data-big') or img_tag.get('src')
|
||||||
|
|
||||||
|
if thumbnail and thumbnail.startswith('/'):
|
||||||
|
thumbnail = "https://www.isbn.de" + thumbnail
|
||||||
|
|
||||||
|
# 8. PREIS EXTRAHIEREN (Neu integriert)
|
||||||
|
price = None
|
||||||
|
# Option A: Aus dem standardisierten Meta-Tag extrahieren (Ergibt z.B. 12.25)
|
||||||
|
price_meta = soup.find('meta', property='product:price:amount')
|
||||||
|
if price_meta and price_meta.get('content'):
|
||||||
|
try:
|
||||||
|
price = float(price_meta.get('content').strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Option B: Fallback über die Infotab-Sidebar (Falls Meta fehlt, filtert "12,25 €*" zu Float)
|
||||||
|
if price is None:
|
||||||
|
price_str = get_sidebar_value('preis')
|
||||||
|
if price_str:
|
||||||
|
# Findet Zahlen wie "12,25" oder "12.25"
|
||||||
|
match = re.search(r'\d+([.,]\d+)?', price_str)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
price = float(match.group(0).replace(',', '.'))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"authors": author,
|
||||||
|
"publisher": publisher,
|
||||||
|
"publishedDate": published_date,
|
||||||
|
"description": description,
|
||||||
|
"pageCount": page_count,
|
||||||
|
"price": price, # Gibt nun z.B. 12.25 als float zurück
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"source": "isbn-de"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"isbn.de Scraping error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route('/fetch_book_info/<isbn>')
|
@app.route('/fetch_book_info/<isbn>')
|
||||||
def fetch_book_info(isbn):
|
def fetch_book_info(isbn):
|
||||||
"""
|
"""
|
||||||
API endpoint to fetch book information by ISBN using Google Books API
|
API endpoint to fetch book information by ISBN using multiple open sources.
|
||||||
|
Optimized for global literature AND German educational books.
|
||||||
Args:
|
|
||||||
isbn (str): ISBN to look up
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Book information or error message
|
|
||||||
"""
|
"""
|
||||||
|
# Authorization Checks
|
||||||
if 'username' not in session or not us.check_admin(session['username']):
|
if 'username' not in session or not us.check_admin(session['username']):
|
||||||
return jsonify({"error": "Not authorized"}), 403
|
return jsonify({"error": "Not authorized"}), 403
|
||||||
|
|
||||||
@@ -9299,79 +9699,34 @@ def fetch_book_info(isbn):
|
|||||||
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Validation
|
||||||
clean_isbn = normalize_and_validate_isbn(isbn)
|
clean_isbn = normalize_and_validate_isbn(isbn)
|
||||||
if not clean_isbn:
|
if not clean_isbn:
|
||||||
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400
|
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400
|
||||||
|
|
||||||
# First source: Google Books
|
# Define the Provider Chain (Order matters: General -> German/School -> Global Fallback)
|
||||||
response = requests.get(
|
providers = [
|
||||||
f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}",
|
_fetch_from_google_books,
|
||||||
timeout=10
|
_fetch_from_lobid_germany,
|
||||||
)
|
_fetch_from_isbn_de,
|
||||||
|
_fetch_from_open_library
|
||||||
|
]
|
||||||
|
|
||||||
if response.status_code == 200:
|
# Iterate through providers until a book is found
|
||||||
data = response.json()
|
for provider in providers:
|
||||||
if data.get('totalItems', 0) > 0 and data.get('items'):
|
book_data = provider(clean_isbn)
|
||||||
book_info = data['items'][0].get('volumeInfo', {})
|
|
||||||
sale_info = data['items'][0].get('saleInfo', {})
|
if book_data:
|
||||||
|
# Add the ISBN back into the response payload
|
||||||
price = None
|
book_data["isbn"] = clean_isbn
|
||||||
retail_price = sale_info.get('retailPrice', {})
|
|
||||||
list_price = sale_info.get('listPrice', {})
|
# If a provider found the book but had no cover, assign a default OpenLibrary cover fallback
|
||||||
if retail_price and 'amount' in retail_price:
|
if not book_data.get("thumbnail"):
|
||||||
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}"
|
book_data["thumbnail"] = f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg"
|
||||||
elif list_price and 'amount' in list_price:
|
|
||||||
price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}"
|
return jsonify(book_data)
|
||||||
|
|
||||||
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
|
|
||||||
if thumbnail:
|
|
||||||
thumbnail = thumbnail.replace('http:', 'https:')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"title": book_info.get('title', 'Unknown Title'),
|
|
||||||
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
|
|
||||||
"publisher": book_info.get('publisher', 'Unknown Publisher'),
|
|
||||||
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
|
|
||||||
"description": book_info.get('description', 'No description available'),
|
|
||||||
"pageCount": book_info.get('pageCount', 'Unknown'),
|
|
||||||
"price": price,
|
|
||||||
"thumbnail": thumbnail,
|
|
||||||
"isbn": clean_isbn,
|
|
||||||
"source": "google-books"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fallback: OpenLibrary
|
|
||||||
ol_response = requests.get(
|
|
||||||
f"https://openlibrary.org/isbn/{clean_isbn}.json",
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
if ol_response.status_code == 200:
|
|
||||||
ol_data = ol_response.json()
|
|
||||||
author_names = []
|
|
||||||
for author_ref in ol_data.get('authors', []):
|
|
||||||
key = author_ref.get('key')
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
author_resp = requests.get(f"https://openlibrary.org{key}.json", timeout=8)
|
|
||||||
if author_resp.status_code == 200:
|
|
||||||
author_names.append(author_resp.json().get('name'))
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"title": ol_data.get('title', 'Unknown Title'),
|
|
||||||
"authors": ', '.join([a for a in author_names if a]) if author_names else 'Unknown Author',
|
|
||||||
"publisher": ', '.join(ol_data.get('publishers', [])) if ol_data.get('publishers') else 'Unknown Publisher',
|
|
||||||
"publishedDate": ol_data.get('publish_date', 'Unknown Date'),
|
|
||||||
"description": (ol_data.get('description', {}).get('value') if isinstance(ol_data.get('description'), dict) else ol_data.get('description')) or 'No description available',
|
|
||||||
"pageCount": ol_data.get('number_of_pages', 'Unknown'),
|
|
||||||
"price": None,
|
|
||||||
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
|
|
||||||
"isbn": clean_isbn,
|
|
||||||
"source": "openlibrary"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
# If all providers fail
|
||||||
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -9922,10 +10277,18 @@ def notifications_unread_status():
|
|||||||
@app.route('/admin/damaged_items')
|
@app.route('/admin/damaged_items')
|
||||||
def admin_damaged_items():
|
def admin_damaged_items():
|
||||||
"""Dedicated admin management window for damaged items."""
|
"""Dedicated admin management window for damaged items."""
|
||||||
if 'username' not in session or not us.check_admin(session['username']):
|
if 'username' not in session:
|
||||||
flash('Administratorrechte erforderlich.', 'error')
|
flash('Administratorrechte erforderlich.', 'error')
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
'''
|
||||||
|
permissions = _get_current_user_permissions()
|
||||||
|
if not _action_access_allowed(permissions, 'can_manage_settings'):
|
||||||
|
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
|
||||||
|
if cfg.MODULES.is_enabled('library'):
|
||||||
|
return redirect(url_for('library_admin'))
|
||||||
|
'''
|
||||||
|
|
||||||
client = None
|
client = None
|
||||||
try:
|
try:
|
||||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||||
@@ -9991,6 +10354,7 @@ def admin_damaged_items():
|
|||||||
damaged_items=damaged_rows,
|
damaged_items=damaged_rows,
|
||||||
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'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ def _match_inventory(path):
|
|||||||
if path == '/' or path.startswith('/home'): return True
|
if path == '/' or path.startswith('/home'): return True
|
||||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
|
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
|
||||||
|
|
||||||
|
|
||||||
def _match_terminplan(path):
|
def _match_terminplan(path):
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
@@ -292,7 +291,7 @@ def _match_terminplan(path):
|
|||||||
|
|
||||||
def _match_library(path):
|
def _match_library(path):
|
||||||
if not path: return False
|
if not path: return False
|
||||||
return path.startswith(('/library', '/library_', '/student_cards'))
|
return path.startswith(('/library', '/library_', '/student_cards', '/admin_damaged_items', '/admin_borrowings', '/admin/library'))
|
||||||
|
|
||||||
def _match_student_cards(path):
|
def _match_student_cards(path):
|
||||||
if not path: return False
|
if not path: return False
|
||||||
|
|||||||
+208
-59
@@ -18,9 +18,19 @@ Collection Structure:
|
|||||||
- Status fields: slots_used_by
|
- Status fields: slots_used_by
|
||||||
"""
|
"""
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
|
import Web.modules.inventarsystem.data_protection as dp
|
||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import datetime
|
import datetime
|
||||||
|
import ast
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tenant_db(client):
|
||||||
|
try:
|
||||||
|
from tenant import get_tenant_db
|
||||||
|
return get_tenant_db(client)
|
||||||
|
except Exception:
|
||||||
|
return client[cfg.MONGODB_DB]
|
||||||
|
|
||||||
def _active_record_query(extra_query=None):
|
def _active_record_query(extra_query=None):
|
||||||
"""Build a query that excludes logically deleted records."""
|
"""Build a query that excludes logically deleted records."""
|
||||||
@@ -29,11 +39,50 @@ def _active_record_query(extra_query=None):
|
|||||||
base_query.update(extra_query)
|
base_query.update(extra_query)
|
||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
def _decrypt_appointment(item):
|
||||||
|
"""Helper function to safely decrypt appointment fields back to their original types."""
|
||||||
|
if not item:
|
||||||
|
return item
|
||||||
|
|
||||||
|
try:
|
||||||
|
if 'user' in item and item['user']:
|
||||||
|
item['user'] = dp.decrypt_text(item['user'])
|
||||||
|
|
||||||
|
if 'note' in item and item['note']:
|
||||||
|
item['note'] = dp.decrypt_text(item['note'])
|
||||||
|
|
||||||
|
if 'title' in item and item['title']:
|
||||||
|
item['title'] = dp.decrypt_text(item['title'])
|
||||||
|
|
||||||
|
if 'mail' in item and item['mail']:
|
||||||
|
decrypted_mail = dp.decrypt_text(item['mail'])
|
||||||
|
try:
|
||||||
|
item['mail'] = ast.literal_eval(decrypted_mail)
|
||||||
|
except Exception:
|
||||||
|
item['mail'] = decrypted_mail
|
||||||
|
|
||||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str=""):
|
if 'custom_fields' in item and item['custom_fields']:
|
||||||
|
item['custom_fields'] = [dp.decrypt_text(field) for field in item['custom_fields']]
|
||||||
|
|
||||||
|
if 'slots_booked' in item and item['slots_booked']:
|
||||||
|
# If it's a string, it was encrypted during an update execution
|
||||||
|
if isinstance(item['slots_booked'], str):
|
||||||
|
decrypted_slots = dp.decrypt_text(item['slots_booked'])
|
||||||
|
try:
|
||||||
|
item['slots_booked'] = ast.literal_eval(decrypted_slots)
|
||||||
|
except Exception:
|
||||||
|
item['slots_booked'] = decrypted_slots
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during decryption: {e}")
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_p_slot: int=1):
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
item = {
|
item = {
|
||||||
@@ -42,58 +91,53 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
'time_span': time_span,
|
'time_span': time_span,
|
||||||
'slots': slots,
|
'slots': slots,
|
||||||
'slot_lenght': slot_lenght,
|
'slot_lenght': slot_lenght,
|
||||||
'user': user,
|
'user': dp.encrypt_text(user.strip()),
|
||||||
'mail': mail,
|
'mail': dp.encrypt_text(str(mail)),
|
||||||
'note': note,
|
'note': dp.encrypt_text(note),
|
||||||
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
'title': dp.encrypt_text(title),
|
||||||
|
'custom_fields': [dp.encrypt_text(str(field)) for field in custom_fields],
|
||||||
|
'calendar_enabled': bool(calendar_enabled),
|
||||||
|
'clients_per_slot': clients_p_slot,
|
||||||
|
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||||
'Created': datetime.datetime.now(),
|
'Created': datetime.datetime.now(),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
result = items.insert_one(item)
|
result = items.insert_one(item)
|
||||||
return result.inserted_id
|
return result.inserted_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Exception accured: {e}")
|
print(f"Exception occurred in add: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
def get_item(id):
|
def get_item(id):
|
||||||
"""
|
"""Retrieve a specific appointment by its ID and decrypt it."""
|
||||||
Retrieve a specific appointment by its ID.
|
client = None
|
||||||
|
|
||||||
Args:
|
|
||||||
id (str): ID of the appointsment to retrieve
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: The appointment document or None if not found
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
||||||
client.close()
|
|
||||||
return item
|
return _decrypt_appointment(item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error retrieving item: {e}")
|
print(f"Error retrieving item: {e}")
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
def update(id,slots_used: list):
|
def update(id, slots_used: list):
|
||||||
"""
|
"""Update an existing appointment's booked slots securely."""
|
||||||
Update an existing appointment.
|
client = None
|
||||||
|
|
||||||
Args:
|
|
||||||
id (str): ID of the item to update
|
|
||||||
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
update_data = {
|
update_data = {
|
||||||
'slots_booked': [slots_used],
|
'slots_booked': dp.encrypt_text(str(slots_used)),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,64 +146,169 @@ def update(id,slots_used: list):
|
|||||||
{'$set': update_data}
|
{'$set': update_data}
|
||||||
)
|
)
|
||||||
|
|
||||||
client.close()
|
|
||||||
return result.modified_count > 0
|
return result.modified_count > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating item: {e}")
|
print(f"Error updating item: {e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
def remove_slot(id, date_start_time, name):
|
def remove_slot(id, date_start_time, name):
|
||||||
"""
|
"""
|
||||||
Remove a booked slot from an appointment's `slots_booked`.
|
Remove a booked slot from an appointment's encrypted `slots_booked` list.
|
||||||
|
|
||||||
Args:
|
Because the array is stored as an encrypted string blob, we must decrypt,
|
||||||
id (str): Appointment ID
|
modify it in Python, and re-encrypt it.
|
||||||
date_start_time: The start time value used when booking
|
|
||||||
name (str): Name associated with the booking
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if a slot was removed, False otherwise
|
|
||||||
"""
|
"""
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
# Attempt to pull the exact element (stored as an array/tuple)
|
item = items.find_one({'_id': ObjectId(id)})
|
||||||
|
if not item or 'slots_booked' not in item or not item['slots_booked']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted_slots = dp.decrypt_text(item['slots_booked'])
|
||||||
|
slots_list = ast.literal_eval(decrypted_slots)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to decrypt or parse slots: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Structure format note: [(start_time, (names), (custom1, custom2...)), ...]
|
||||||
|
updated_slots = []
|
||||||
|
removed_any = False
|
||||||
|
|
||||||
|
for slot in slots_list:
|
||||||
|
slot_start = slot[0]
|
||||||
|
slot_names = slot[1]
|
||||||
|
|
||||||
|
|
||||||
|
if slot_start == date_start_time and (slot_names == name or name in slot_names):
|
||||||
|
removed_any = True
|
||||||
|
continue
|
||||||
|
updated_slots.append(slot)
|
||||||
|
|
||||||
|
if not removed_any:
|
||||||
|
return False
|
||||||
|
|
||||||
result = items.update_one(
|
result = items.update_one(
|
||||||
{'_id': ObjectId(id)},
|
{'_id': ObjectId(id)},
|
||||||
{'$pull': {'slots_booked': [date_start_time, name]}}
|
{
|
||||||
|
'$set': {
|
||||||
|
'slots_booked': dp.encrypt_text(str(updated_slots)),
|
||||||
|
'LastUpdated': datetime.datetime.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
client.close()
|
|
||||||
return result.modified_count > 0
|
return result.modified_count > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error removing slot: {e}")
|
print(f"Error removing slot: {e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def remove(id):
|
def remove(id):
|
||||||
"""
|
"""
|
||||||
Soft-delete an appointment by setting its `Deleted` flag.
|
Hard-delete an appointment plan by its ID.
|
||||||
|
(Note: If your docstring mentions a soft-delete 'Deleted' flag,
|
||||||
Args:
|
change items.delete_one to items.update_one with {'$set': {'Deleted': True}})
|
||||||
id (str): Appointment ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the appointment was marked deleted, False otherwise
|
|
||||||
"""
|
"""
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
result = items.delete_one({'_id': ObjectId(id)})
|
result = items.delete_one({'_id': ObjectId(id)})
|
||||||
|
|
||||||
client.close()
|
return result.deleted_count > 0
|
||||||
return result.modified_count > 0
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error removing appointment: {e}")
|
print(f"Error removing appointment: {e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_done():
|
||||||
|
"""Remove all expired appointments whose end date is prior to today in a single call."""
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = _get_tenant_db(client)
|
||||||
|
items = db['appointments']
|
||||||
|
|
||||||
|
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
result = items.delete_many(
|
||||||
|
_active_record_query(
|
||||||
|
{
|
||||||
|
'date_end': {'$lt': today},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.deleted_count > 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error cleaning up finished appointments: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_upcoming_for_user(user: str, limit: int = 25):
|
||||||
|
"""
|
||||||
|
Return upcoming appointment plans for a user, handling encrypted database records.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if hasattr(globals(), 'remove_done'):
|
||||||
|
remove_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = _get_tenant_db(client)
|
||||||
|
items = db['appointments']
|
||||||
|
|
||||||
|
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||||
|
target_user = str(user or '').strip()
|
||||||
|
|
||||||
|
cursor = items.find(
|
||||||
|
_active_record_query({
|
||||||
|
'date_end': {'$gte': today},
|
||||||
|
})
|
||||||
|
).sort('date_start', 1)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for item in cursor:
|
||||||
|
decrypted_item = _decrypt_appointment(item)
|
||||||
|
if not decrypted_item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if decrypted_item.get('user', '').strip() != target_user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
decrypted_item['_id'] = str(decrypted_item.get('_id'))
|
||||||
|
results.append(decrypted_item)
|
||||||
|
|
||||||
|
if len(results) >= max(1, int(limit)):
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error retrieving upcoming appointments: {e}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
+90
-105
@@ -20,6 +20,8 @@ import string
|
|||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger('app')
|
logger = logging.getLogger('app')
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -430,103 +432,84 @@ def check_password_strength(password):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def hashing(password):
|
def hashing(password, salt=None):
|
||||||
"""
|
"""
|
||||||
Hash a password using SHA-512.
|
Hasht ein Passwort mit scrypt.
|
||||||
|
- Wenn kein Salt übergeben wird, wird ein sicherer, zufälliger Salt generiert (für neue Passwörter).
|
||||||
|
- Format für neue Hashes: v1$<salt_hex>$<hash_hex>
|
||||||
|
"""
|
||||||
|
password_bytes = password.encode('utf-8') # Explizit UTF-8 für Plattformunabhängigkeit
|
||||||
|
|
||||||
Args:
|
if salt is None:
|
||||||
password (str): Password to hash
|
# Neuer Benutzer / Passwortänderung -> Dynamischer Salt
|
||||||
|
random_salt = os.urandom(16)
|
||||||
Returns:
|
hashed = hashlib.scrypt(password_bytes, salt=random_salt, n=16384, r=8, p=1)
|
||||||
str: Hexadecimal digest of the hashed password
|
return f"v1${random_salt.hex()}${hashed.hex()}"
|
||||||
|
else:
|
||||||
|
# Bestehender Benutzer (wird zur Verifizierung aufgerufen)
|
||||||
|
hashed = hashlib.scrypt(password_bytes, salt=salt, n=16384, r=8, p=1)
|
||||||
|
return hashed.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(provided_password, stored_password_string):
|
||||||
"""
|
"""
|
||||||
return hashlib.sha512(password.encode()).hexdigest()
|
Verifiziert ein Passwort gegen einen gespeicherten Hash-String.
|
||||||
|
Unterstützt das alte Format (statischer Salt) und das neue Format (v1$...).
|
||||||
|
"""
|
||||||
|
if not stored_password_string:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Überprüfung für das neue, sichere Format
|
||||||
|
if stored_password_string.startswith("v1$"):
|
||||||
|
try:
|
||||||
|
_, salt_hex, hash_hex = stored_password_string.split("$")
|
||||||
|
salt_bytes = bytes.fromhex(salt_hex)
|
||||||
|
# Berechne den Hash des eingegebenen Passworts mit dem extrahierten Salt
|
||||||
|
calculated_hash = hashing(provided_password, salt=salt_bytes)
|
||||||
|
# Timing-Attack-sicherer Vergleich
|
||||||
|
return hmac.compare_digest(calculated_hash, hash_hex)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.error("Ungültiges Hash-Format in der Datenbank entdeckt.")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Abwärtskompatibilität: Altes Format mit statischem Salt b'some_salt'
|
||||||
|
old_static_salt = b'some_salt'
|
||||||
|
calculated_hash = hashing(provided_password, salt=old_static_salt)
|
||||||
|
return hmac.compare_digest(calculated_hash, stored_password_string)
|
||||||
|
|
||||||
|
|
||||||
def check_nm_pwd(username, password):
|
def check_nm_pwd(username, password):
|
||||||
"""
|
"""
|
||||||
Verify username and password combination.
|
Überprüft die Kombination aus Benutzername und Passwort (optimiert).
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username to check
|
|
||||||
password (str): Password to verify
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: User document if credentials are valid, None otherwise
|
|
||||||
"""
|
"""
|
||||||
db_name, tenant_id = _resolve_request_tenant_db()
|
db_name, tenant_id = _resolve_request_tenant_db()
|
||||||
ctx = None
|
|
||||||
try:
|
|
||||||
from tenant import get_tenant_context
|
|
||||||
ctx = get_tenant_context()
|
|
||||||
except Exception:
|
|
||||||
ctx = None
|
|
||||||
|
|
||||||
if not db_name:
|
if not db_name:
|
||||||
if _has_tenant_configs():
|
if _has_tenant_configs():
|
||||||
logger.warning(
|
logger.warning("Default DB fallback verweigert, da Tenant-Konfigurationen existieren.")
|
||||||
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
db_name = cfg.MONGODB_DB
|
db_name = cfg.MONGODB_DB
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
|
|
||||||
username,
|
|
||||||
tenant_id,
|
|
||||||
db_name,
|
|
||||||
cfg.MONGODB_HOST,
|
|
||||||
cfg.MONGODB_PORT,
|
|
||||||
getattr(cfg, 'MONGODB_URI', None),
|
|
||||||
)
|
|
||||||
|
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
try:
|
try:
|
||||||
hashed_password = hashing(password)
|
|
||||||
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
|
|
||||||
available_dbs = []
|
|
||||||
try:
|
|
||||||
available_dbs = client.list_database_names()
|
|
||||||
logger.debug("MongoDB connected. Available databases=%s", available_dbs)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unable to list MongoDB databases: %s", exc)
|
|
||||||
|
|
||||||
db = client[db_name]
|
db = client[db_name]
|
||||||
try:
|
|
||||||
existing_collections = db.list_collection_names()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
|
|
||||||
existing_collections = []
|
|
||||||
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
|
|
||||||
|
|
||||||
users = db['users']
|
users = db['users']
|
||||||
|
|
||||||
query = {'$or': [{'Username': username}, {'username': username}]}
|
query = {'$or': [{'Username': username}, {'username': username}]}
|
||||||
logger.debug("Running user lookup on %r: %s", db_name, query)
|
|
||||||
user_record = users.find_one(query)
|
user_record = users.find_one(query)
|
||||||
|
|
||||||
if user_record is None:
|
if user_record is None:
|
||||||
logger.warning("No user document found in db=%r for username=%r", db_name, username)
|
logger.warning("Kein Benutzer für %r in DB %r gefunden.", username, db_name)
|
||||||
if db_name not in available_dbs:
|
|
||||||
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
|
|
||||||
if 'users' not in existing_collections:
|
|
||||||
logger.warning("Tenant database %r has no users collection", db_name)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
|
|
||||||
stored_password = user_record.get('Password') or user_record.get('password')
|
stored_password = user_record.get('Password') or user_record.get('password')
|
||||||
if stored_password is None:
|
|
||||||
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
|
if not verify_password(password, stored_password):
|
||||||
|
logger.warning("Falsches Passwort für Benutzer %r in DB %r.", username, db_name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if stored_password != hashed_password:
|
# Automatische Migration alter Hashes auf das neue Format
|
||||||
logger.warning(
|
if not stored_password.startswith("v1$"):
|
||||||
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
|
users.update_one({'_id': user_record['_id']}, {'$set': {'Password': hashing(password)}})
|
||||||
username,
|
|
||||||
db_name,
|
|
||||||
hashed_password,
|
|
||||||
stored_password,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return user_record
|
return user_record
|
||||||
finally:
|
finally:
|
||||||
@@ -556,44 +539,46 @@ def add_user(
|
|||||||
bool: True if user was added successfully, False if password was too weak
|
bool: True if user was added successfully, False if password was too weak
|
||||||
"""
|
"""
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = _get_tenant_db(client)
|
try:
|
||||||
users = db['users']
|
db = _get_tenant_db(client)
|
||||||
if not check_password_strength(password):
|
users = db['users']
|
||||||
return False
|
if not check_password_strength(password):
|
||||||
permission_defaults = build_default_permission_payload(permission_preset)
|
return False
|
||||||
if isinstance(action_permissions, dict):
|
permission_defaults = build_default_permission_payload(permission_preset)
|
||||||
for key, value in action_permissions.items():
|
if isinstance(action_permissions, dict):
|
||||||
permission_defaults['actions'][str(key)] = bool(value)
|
for key, value in action_permissions.items():
|
||||||
if isinstance(page_permissions, dict):
|
permission_defaults['actions'][str(key)] = bool(value)
|
||||||
for key, value in page_permissions.items():
|
if isinstance(page_permissions, dict):
|
||||||
permission_defaults['pages'][str(key)] = bool(value)
|
for key, value in page_permissions.items():
|
||||||
|
permission_defaults['pages'][str(key)] = bool(value)
|
||||||
|
|
||||||
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.strip() if name else '',
|
'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'],
|
||||||
'ActionPermissions': permission_defaults['actions'],
|
'ActionPermissions': permission_defaults['actions'],
|
||||||
'PagePermissions': permission_defaults['pages'],
|
'PagePermissions': permission_defaults['pages'],
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized_card = normalize_student_card_id(student_card_id)
|
normalized_card = normalize_student_card_id(student_card_id)
|
||||||
if bool(is_student):
|
if bool(is_student):
|
||||||
if normalized_card:
|
if normalized_card:
|
||||||
user_doc['StudentCardId'] = normalized_card
|
user_doc['StudentCardId'] = normalized_card
|
||||||
if max_borrow_days is not None:
|
if max_borrow_days is not None:
|
||||||
try:
|
try:
|
||||||
user_doc['MaxBorrowDays'] = int(max_borrow_days)
|
user_doc['MaxBorrowDays'] = int(max_borrow_days)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
users.insert_one(user_doc)
|
users.insert_one(user_doc)
|
||||||
client.close()
|
return True
|
||||||
return True
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def student_card_exists(student_card_id):
|
def student_card_exists(student_card_id):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from modules.module_registry import ModuleRegistry as mr
|
||||||
import smtplib
|
import smtplib
|
||||||
|
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
@@ -27,21 +28,24 @@ def send(email: list, subject: str, note: str, sender: str) -> bool:
|
|||||||
Output:
|
Output:
|
||||||
- bool: true if the sending worked and false if it didnt
|
- bool: true if the sending worked and false if it didnt
|
||||||
"""
|
"""
|
||||||
msg = MIMEMultipart()
|
if not mr.registry.is_enabled('mail'):
|
||||||
msg['Subject'] = subject
|
|
||||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
|
||||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
|
||||||
msg.attach(MIMEText(note))
|
|
||||||
smtp = None
|
|
||||||
try:
|
|
||||||
smtp = _build_smtp_client()
|
|
||||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
return False
|
||||||
finally:
|
else:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||||
|
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||||
|
msg.attach(MIMEText(note))
|
||||||
|
smtp = None
|
||||||
try:
|
try:
|
||||||
if smtp:
|
smtp = _build_smtp_client()
|
||||||
smtp.quit()
|
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return False
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if smtp:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -2,10 +2,39 @@
|
|||||||
Class for all funktions of the executive -> Lehrer
|
Class for all funktions of the executive -> Lehrer
|
||||||
"""
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import emailservice.email as mail_service
|
from datetime import timedelta
|
||||||
|
from flask import url_for, has_request_context, request
|
||||||
|
import Web.modules.emailservice.email as mail_service
|
||||||
import Web.modules.database.termine as termin
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from tenant import get_tenant_context
|
from Web.tenant import get_tenant_context
|
||||||
|
import Web.modules.inventarsystem.data_protection as dp
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_public_base_url() -> str:
|
||||||
|
if has_request_context():
|
||||||
|
try:
|
||||||
|
return request.url_root.rstrip('/')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tenant_context = get_tenant_context()
|
||||||
|
subdomain = ''
|
||||||
|
if tenant_context:
|
||||||
|
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||||
|
return f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
|
||||||
|
|
||||||
|
|
||||||
|
def _current_tenant_id() -> str:
|
||||||
|
tenant_context = get_tenant_context()
|
||||||
|
if tenant_context and getattr(tenant_context, 'tenant_id', None):
|
||||||
|
return str(tenant_context.tenant_id)
|
||||||
|
if has_request_context():
|
||||||
|
try:
|
||||||
|
return str(request.args.get('tenant', '') or request.args.get('tenant_id', '') or '').strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def _normalize_time_span(time_span):
|
def _normalize_time_span(time_span):
|
||||||
@@ -32,52 +61,209 @@ def _normalize_mail_list(mail):
|
|||||||
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
|
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="") -> str:
|
def _escape_ics_text(value):
|
||||||
|
return str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,').replace('\r\n', '\n').replace('\n', '\\n').replace('\r', '')
|
||||||
|
|
||||||
|
|
||||||
|
def _format_ics_date(date_value):
|
||||||
|
if isinstance(date_value, datetime.datetime):
|
||||||
|
return date_value.strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
if isinstance(date_value, datetime.date):
|
||||||
|
return date_value.strftime('%Y%m%d')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def build_calendar_ics(appointment_id: str) -> str | None:
|
||||||
|
item = termin.get_item(appointment_id)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
date_start = item.get('date_start')
|
||||||
|
date_end = item.get('date_end')
|
||||||
|
time_span = item.get('time_span', []) or []
|
||||||
|
creator = item.get('user', 'Terminplaner')
|
||||||
|
note = item.get('note', '') or ''
|
||||||
|
titel = item.get('title', '') or ''
|
||||||
|
tenant_id = _current_tenant_id()
|
||||||
|
try:
|
||||||
|
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||||
|
except Exception:
|
||||||
|
host = _resolve_public_base_url()
|
||||||
|
link = host + "/terminplaner/client/" + str(appointment_id)
|
||||||
|
if tenant_id:
|
||||||
|
link += f"?tenant={tenant_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.datetime.strptime(str(date_end), '%Y-%m-%d').date()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
uid = f"terminplaner-{appointment_id}@invario.eu"
|
||||||
|
|
||||||
|
created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
|
||||||
|
summary = titel if titel else f"Terminplan für {creator}"
|
||||||
|
|
||||||
|
description_lines = [
|
||||||
|
f"Buchungslink: {link}",
|
||||||
|
f"Zeitraum: {date_start} bis {date_end}",
|
||||||
|
]
|
||||||
|
if time_span:
|
||||||
|
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
||||||
|
if note:
|
||||||
|
description_lines.append('Notiz: ' + str(note))
|
||||||
|
if titel and not summary == titel:
|
||||||
|
description_lines.append('Titel: ' + str(titel))
|
||||||
|
|
||||||
|
ics_lines = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Inventarsystem//Terminplaner//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
f'UID:{uid}',
|
||||||
|
f'DTSTAMP:{created_at}',
|
||||||
|
f'SUMMARY:{_escape_ics_text(summary)}',
|
||||||
|
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
||||||
|
f'URL:{_escape_ics_text(link)}',
|
||||||
|
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
||||||
|
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + datetime.timedelta(days=1))}',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
return '\r\n'.join(ics_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str = '') -> str | None:
|
||||||
|
"""Build a single-slot ICS export for a client booking candidate."""
|
||||||
|
item = termin.get_item(appointment_id)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_dt = datetime.datetime.strptime(str(slot_start).strip(), '%Y-%m-%d %H:%M')
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
slot_minutes = int(item.get('slot_length') or item.get('slot_lenght') or 0)
|
||||||
|
except Exception:
|
||||||
|
slot_minutes = 0
|
||||||
|
|
||||||
|
if slot_minutes <= 0:
|
||||||
|
slot_minutes = 45
|
||||||
|
|
||||||
|
end_dt = start_dt + datetime.timedelta(minutes=slot_minutes)
|
||||||
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
|
try:
|
||||||
|
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||||
|
except Exception:
|
||||||
|
host = _resolve_public_base_url()
|
||||||
|
link = host + '/terminplaner/client/' + str(appointment_id)
|
||||||
|
if tenant_id:
|
||||||
|
link += f'?tenant={tenant_id}'
|
||||||
|
|
||||||
|
title_name = item.get('title') or f"Terminbuchung mit {client_name}" if client_name else "Terminbuchung"
|
||||||
|
summary = f"{title_name} - Terminbuchung"
|
||||||
|
description_lines = [
|
||||||
|
f"Buchungslink: {link}",
|
||||||
|
f"Geplanter Termin: {start_dt.strftime('%d.%m.%Y %H:%M')} - {end_dt.strftime('%H:%M')}",
|
||||||
|
]
|
||||||
|
|
||||||
|
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
|
||||||
|
created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
|
||||||
|
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
|
||||||
|
|
||||||
|
ics_lines = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Inventarsystem//Terminplaner Client Slot//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
f'UID:{uid}',
|
||||||
|
f'DTSTAMP:{created_at}',
|
||||||
|
f'SUMMARY:{_escape_ics_text(summary)}',
|
||||||
|
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
||||||
|
f'URL:{_escape_ics_text(link)}',
|
||||||
|
f'DTSTART:{dt_start}',
|
||||||
|
f'DTEND:{dt_end}',
|
||||||
|
f'Titel:{_escape_ics_text(summary)}',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
return '\r\n'.join(ics_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_per_slot: int=1) -> dict:
|
||||||
"""
|
"""
|
||||||
Generates a link for the executive to send to his clients to book a time Slot
|
Generates a link for the executive to send to his clients to book a time Slot
|
||||||
|
|
||||||
Input:
|
|
||||||
- date_start: start of the time frae area
|
|
||||||
- date_end: end of the time frame area
|
|
||||||
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
|
|
||||||
- slots: amount of slots that are available
|
|
||||||
- slot_lenght: the lenght of a slot in minutes
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- link: The link for the user to send to the clients
|
|
||||||
"""
|
|
||||||
normalized_time_span = _normalize_time_span(time_span)
|
|
||||||
normalized_mail = _normalize_mail_list(mail)
|
|
||||||
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note)
|
|
||||||
|
|
||||||
tenant_context = get_tenant_context()
|
|
||||||
subdomain = ''
|
|
||||||
if tenant_context:
|
|
||||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
|
||||||
|
|
||||||
host = f"https://{subdomain}.invario.eu" if subdomain else "invario.eu"
|
|
||||||
link = host + "/terminplaner/client" + "?" + "client_id=" + id
|
|
||||||
subject = f"Terminanfrage von {user}"
|
|
||||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
|
||||||
if normalized_mail:
|
|
||||||
mail_service.send(normalized_mail, subject, note_link)
|
|
||||||
return link
|
|
||||||
|
|
||||||
|
|
||||||
def book_slot(id, date_start_time, name):
|
|
||||||
"""
|
|
||||||
Updates slot for the booking per a id
|
|
||||||
|
|
||||||
Input:
|
|
||||||
- id: the id is the id you get from the
|
|
||||||
- date_start_time: the date of the booking that was selected with date and time
|
|
||||||
- name: name that the client gave himself
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- bool: if worked or not
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Retrieve the current appointment
|
slots_int = int(slots)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
slots_int = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
custom_fields.pop(-1)
|
||||||
|
except :
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
slot_length_int = int(slot_length)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
slot_length_int = 45
|
||||||
|
|
||||||
|
normalized_time_span = _normalize_time_span(time_span)
|
||||||
|
normalized_mail = _normalize_mail_list(mail or [])
|
||||||
|
|
||||||
|
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields, clients_p_slot=clients_per_slot)
|
||||||
|
id_str = str(id)
|
||||||
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
|
try:
|
||||||
|
link = url_for('terminplaner.client', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||||
|
except Exception:
|
||||||
|
host = _resolve_public_base_url()
|
||||||
|
link = host + "/terminplaner/client/" + id_str
|
||||||
|
if tenant_id:
|
||||||
|
link += f"?tenant={tenant_id}"
|
||||||
|
|
||||||
|
subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
|
||||||
|
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||||
|
calendar_link = None
|
||||||
|
|
||||||
|
if calendar_enabled:
|
||||||
|
try:
|
||||||
|
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||||
|
except Exception:
|
||||||
|
host = _resolve_public_base_url()
|
||||||
|
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
|
||||||
|
if tenant_id:
|
||||||
|
calendar_link += f"?tenant={tenant_id}"
|
||||||
|
|
||||||
|
email_body = note_link
|
||||||
|
if calendar_link:
|
||||||
|
email_body += f"\n\nKalendereintrag: {calendar_link}"
|
||||||
|
|
||||||
|
if normalized_mail and cfg.EMAIL_ENABLED:
|
||||||
|
mail_service.send(normalized_mail, subject, email_body)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'appointment_id': id_str,
|
||||||
|
'link': link,
|
||||||
|
'calendar_link': calendar_link,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def book_slot(id, date_start_time, name, custom: tuple = ()):
|
||||||
|
try:
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
@@ -86,40 +272,44 @@ def book_slot(id, date_start_time, name):
|
|||||||
if not isinstance(slots, list):
|
if not isinstance(slots, list):
|
||||||
slots = []
|
slots = []
|
||||||
|
|
||||||
|
# 1. Check overall total booking capacity
|
||||||
capacity = int(item.get('slots', 0) or 0)
|
capacity = int(item.get('slots', 0) or 0)
|
||||||
if capacity and len(slots) >= capacity:
|
if capacity and len(slots) >= capacity:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 2. Prevent the exact same user from double-booking the exact same slot
|
||||||
for existing in slots:
|
for existing in slots:
|
||||||
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
||||||
if existing[0] == date_start_time and existing[1] == name:
|
if existing[0] == date_start_time and existing[1] == name:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Append the new booking as a tuple (start_time, name)
|
# 3. Check concurrent capacity for this specific time slot
|
||||||
slots.append((date_start_time, name))
|
clients_per_slot = int(item.get('clients_per_slot', 1) or 1)
|
||||||
|
bookings_at_time = sum(
|
||||||
|
1 for existing in slots
|
||||||
|
if isinstance(existing, (list, tuple)) and len(existing) > 0 and existing[0] == date_start_time
|
||||||
|
)
|
||||||
|
|
||||||
|
if bookings_at_time >= clients_per_slot:
|
||||||
|
return False
|
||||||
|
|
||||||
# Update the appointment in the database
|
# Append the new booking successfully
|
||||||
|
slots.append((date_start_time, name, custom))
|
||||||
success = termin.update(id, slots)
|
success = termin.update(id, slots)
|
||||||
return bool(success)
|
return bool(success)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error booking slot: {e}")
|
print(f"Error booking slot: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def remove_slot(id, date_start_time, name):
|
def remove_slot(id, date_start_time, name):
|
||||||
"""
|
|
||||||
Remove a booked slot for an appointment.
|
|
||||||
|
|
||||||
Returns True if the removal succeeded, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Prefer DB-level remove if available
|
|
||||||
if hasattr(termin, 'remove_slot'):
|
if hasattr(termin, 'remove_slot'):
|
||||||
removed = termin.remove_slot(id, date_start_time, name)
|
removed = termin.remove_slot(id, date_start_time, name)
|
||||||
if removed:
|
if removed:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Fallback: fetch, filter, and replace the slot list
|
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
@@ -128,7 +318,6 @@ def remove_slot(id, date_start_time, name):
|
|||||||
new_slots = []
|
new_slots = []
|
||||||
for s in slots:
|
for s in slots:
|
||||||
try:
|
try:
|
||||||
# s may be list or tuple like [start_time, name]
|
|
||||||
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
|
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -143,9 +332,6 @@ def remove_slot(id, date_start_time, name):
|
|||||||
|
|
||||||
|
|
||||||
def remove_appointment(id):
|
def remove_appointment(id):
|
||||||
"""
|
|
||||||
Remove an entire appointment by id.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
return bool(termin.remove(id))
|
return bool(termin.remove(id))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -153,17 +339,6 @@ def remove_appointment(id):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_available(id):
|
def get_available(id):
|
||||||
"""
|
|
||||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
|
||||||
And checks if there are slots left.
|
|
||||||
|
|
||||||
Input:
|
|
||||||
- id: id of the appointment
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
|
||||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
termin_range = termin.get_item(id)
|
termin_range = termin.get_item(id)
|
||||||
if not termin_range:
|
if not termin_range:
|
||||||
@@ -172,20 +347,45 @@ def get_available(id):
|
|||||||
date_start = termin_range.get('date_start')
|
date_start = termin_range.get('date_start')
|
||||||
date_end = termin_range.get('date_end')
|
date_end = termin_range.get('date_end')
|
||||||
time_span = termin_range.get('time_span', [])
|
time_span = termin_range.get('time_span', [])
|
||||||
slot_lenght = termin_range.get('slot_lenght')
|
|
||||||
total_slots = termin_range.get('slots', 0)
|
# Safe integer parsing without heavy try-except blocks
|
||||||
|
try:
|
||||||
|
total_slots = int(termin_range.get('slots', 0) or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
total_slots = 0
|
||||||
|
|
||||||
|
# Support both spellings gracefully
|
||||||
|
raw_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0
|
||||||
|
try:
|
||||||
|
slot_length = int(raw_length)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
slot_length = raw_length
|
||||||
|
|
||||||
|
clients_per_slot = int(termin_range.get('clients_per_slot', 1) or 1)
|
||||||
booked = termin_range.get('slots_booked', []) or []
|
booked = termin_range.get('slots_booked', []) or []
|
||||||
|
|
||||||
# Normalize booked entries to dicts for easier consumption
|
# Normalize the bookings list safely
|
||||||
normalized = []
|
normalized = []
|
||||||
|
bookings_by_time = {} # Tracks how many people are in each specific time slot
|
||||||
|
|
||||||
for s in booked:
|
for s in booked:
|
||||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||||
normalized.append({'start': s[0], 'name': s[1]})
|
slot_time = s[0]
|
||||||
|
item = {'start': slot_time, 'name': s[1]}
|
||||||
elif isinstance(s, dict):
|
elif isinstance(s, dict):
|
||||||
normalized.append(s)
|
slot_time = s.get('start')
|
||||||
|
item = s
|
||||||
else:
|
else:
|
||||||
normalized.append({'value': s})
|
slot_time = str(s)
|
||||||
|
item = {'value': s}
|
||||||
|
|
||||||
|
normalized.append(item)
|
||||||
|
|
||||||
|
# Count concurrent bookings per timestamp
|
||||||
|
if slot_time:
|
||||||
|
bookings_by_time[slot_time] = bookings_by_time.get(slot_time, 0) + 1
|
||||||
|
|
||||||
|
# Calculate remaining total capacity safely
|
||||||
slots_used = len(normalized)
|
slots_used = len(normalized)
|
||||||
slots_left = max(0, total_slots - slots_used)
|
slots_left = max(0, total_slots - slots_used)
|
||||||
|
|
||||||
@@ -193,25 +393,70 @@ def get_available(id):
|
|||||||
'date_start': date_start,
|
'date_start': date_start,
|
||||||
'date_end': date_end,
|
'date_end': date_end,
|
||||||
'time_span': time_span,
|
'time_span': time_span,
|
||||||
'slot_lenght': slot_lenght,
|
'slot_length': slot_length,
|
||||||
|
'slot_lenght': slot_length,
|
||||||
'slots_total': total_slots,
|
'slots_total': total_slots,
|
||||||
|
'clients_per_slot': clients_per_slot,
|
||||||
'slots_booked': normalized,
|
'slots_booked': normalized,
|
||||||
'slots_left': slots_left,
|
'slots_left': slots_left,
|
||||||
|
'bookings_by_time': bookings_by_time
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting available slots: {e}")
|
print(f"Error getting available slots: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_available_user(id):
|
def get_available_user(id):
|
||||||
"""
|
return get_available(id)
|
||||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
|
||||||
And checks if there are slots left.
|
|
||||||
|
|
||||||
Input:
|
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
||||||
- id: id of the appointment
|
user_name = str(user or '').strip()
|
||||||
|
if not user_name:
|
||||||
|
return []
|
||||||
|
|
||||||
Output:
|
appointments = termin.get_upcoming_for_user(user_name, limit=limit)
|
||||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
host = _resolve_public_base_url()
|
||||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
tenant_id = _current_tenant_id()
|
||||||
"""
|
|
||||||
return get_available(id)
|
result = []
|
||||||
|
for item in appointments:
|
||||||
|
appointment_id = str(item.get('_id') or '')
|
||||||
|
if not appointment_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
link = url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
|
||||||
|
except Exception:
|
||||||
|
link = host + '/terminplaner/client/' + appointment_id
|
||||||
|
if tenant_id:
|
||||||
|
link += f'?tenant={tenant_id}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
calendar_link = url_for('terminplaner.calendar_export', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
|
||||||
|
except Exception:
|
||||||
|
calendar_link = host + '/terminplaner/calendar/' + appointment_id + '.ics'
|
||||||
|
if tenant_id:
|
||||||
|
calendar_link += f'?tenant={tenant_id}'
|
||||||
|
|
||||||
|
slots_total = int(item.get('slots', 0) or 0)
|
||||||
|
slots_booked = item.get('slots_booked', []) or []
|
||||||
|
if not isinstance(slots_booked, list):
|
||||||
|
slots_booked = []
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
'appointment_id': appointment_id,
|
||||||
|
'date_start': str(item.get('date_start') or ''),
|
||||||
|
'date_end': str(item.get('date_end') or ''),
|
||||||
|
'time_span': item.get('time_span', []) or [],
|
||||||
|
'slots_total': slots_total,
|
||||||
|
'slots_booked': len(slots_booked),
|
||||||
|
'slots_left': max(0, slots_total - len(slots_booked)),
|
||||||
|
'note': str(item.get('note') or ''),
|
||||||
|
'link': link,
|
||||||
|
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||||
|
'title': str(item.get('title') or ''),
|
||||||
|
'custom_fields': list(item.get('custom_fields')),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
|
from flask import Blueprint, render_template, request, session, url_for, redirect, flash, make_response, Response, send_file
|
||||||
import Web.modules.terminplaner.backend_server as appointment_service
|
import Web.modules.terminplaner.backend_server as appointment_service
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
|
import Web.modules.database.termine as termin
|
||||||
|
import Web.modules.database.user as us
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import qrcode
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.lib.colors import grey, HexColor
|
||||||
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
|
||||||
|
from Web.app import _get_school_info_for_export
|
||||||
|
|
||||||
|
|
||||||
# Create a blueprint instance
|
# Create a blueprint instance
|
||||||
appoint_bp = Blueprint('terminplaner', __name__)
|
appoint_bp = Blueprint('terminplaner', __name__)
|
||||||
@@ -12,10 +26,89 @@ def _require_module_enabled():
|
|||||||
return redirect(url_for('home'))
|
return redirect(url_for('home'))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _appointment_not_found_response():
|
||||||
|
return render_template(
|
||||||
|
'terminplaner_not_found.html',
|
||||||
|
error_code=404,
|
||||||
|
error_message='Der Termin wurde nicht gefunden.',
|
||||||
|
), 404
|
||||||
|
|
||||||
|
|
||||||
|
def _current_tenant_id():
|
||||||
|
try:
|
||||||
|
from Web.tenant import get_tenant_context
|
||||||
|
ctx = get_tenant_context()
|
||||||
|
if ctx and getattr(ctx, 'tenant_id', None):
|
||||||
|
return str(ctx.tenant_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(session.get('tenant_id', '') or '').strip()
|
||||||
|
|
||||||
|
@appoint_bp.route('/client/<appointment_id>/export_csv')
|
||||||
|
def export_csv(appointment_id):
|
||||||
|
"""
|
||||||
|
Generiert einen CSV-Export aller Buchungen für den Ersteller.
|
||||||
|
"""
|
||||||
|
# 1. Berechtigungsprüfung (analog zur Client-Route)
|
||||||
|
current_user = str(session.get('username', '') or '').strip()
|
||||||
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
|
can_view = False
|
||||||
|
if current_user:
|
||||||
|
try:
|
||||||
|
can_view = bool(us.check_admin(current_user) or current_user == appointment_owner)
|
||||||
|
except Exception:
|
||||||
|
can_view = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
if not can_view:
|
||||||
|
flash('Nicht autorisiert für diesen Export.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
||||||
|
|
||||||
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
bookings = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# 2. CSV im Speicher erstellen
|
||||||
|
si = io.StringIO()
|
||||||
|
# Wir nutzen ein Semikolon als Trennzeichen – das öffnet Excel im deutschsprachigen Raum direkt fehlerfrei
|
||||||
|
cw = csv.writer(si, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||||
|
|
||||||
|
# Tabellenkopf schreiben
|
||||||
|
headers = ['Zeitpunkt', 'Name'] + list(custom_fields)
|
||||||
|
cw.writerow(headers)
|
||||||
|
|
||||||
|
# Datenzeilen schreiben
|
||||||
|
for booking in bookings:
|
||||||
|
if isinstance(booking, (list, tuple)):
|
||||||
|
row = [booking[0], booking[1]]
|
||||||
|
|
||||||
|
# Antworten holen und sicherstellen, dass Lücken (falls vorhanden) mit Leerstrings gefüllt werden
|
||||||
|
answers = booking[2] if len(booking) > 2 else []
|
||||||
|
for i in range(len(custom_fields)):
|
||||||
|
if i < len(answers):
|
||||||
|
row.append(answers[i])
|
||||||
|
else:
|
||||||
|
row.append('')
|
||||||
|
cw.writerow(row)
|
||||||
|
|
||||||
|
# 3. Response vorbereiten und UTF-8-BOM für Excel mitsenden
|
||||||
|
response_content = si.getvalue()
|
||||||
|
output = make_response(response_content)
|
||||||
|
|
||||||
|
output.data = b'\xef\xbb\xbf' + output.data
|
||||||
|
|
||||||
|
# Dateiname generieren
|
||||||
|
title_slug = appointment_item.get('title', 'export').replace(' ', '_')
|
||||||
|
output.headers["Content-Disposition"] = f"attachment; filename=buchungen_{title_slug}.csv"
|
||||||
|
output.headers["Content-Type"] = "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||||
def client(appointment_id):
|
def client(appointment_id):
|
||||||
"""
|
"""
|
||||||
The Route for the terminplaner to work with the client
|
The Route for the terminplaner to work with the client and allow owners to manage it.
|
||||||
"""
|
"""
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
if guard:
|
if guard:
|
||||||
@@ -23,38 +116,150 @@ def client(appointment_id):
|
|||||||
|
|
||||||
available = appointment_service.get_available(appointment_id)
|
available = appointment_service.get_available(appointment_id)
|
||||||
if not available:
|
if not available:
|
||||||
flash('Der Termin wurde nicht gefunden.', 'error')
|
return _appointment_not_found_response()
|
||||||
return redirect(url_for('terminplan'))
|
|
||||||
|
current_user = str(session.get('username', '') or '').strip()
|
||||||
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
|
||||||
|
# Permissions check
|
||||||
|
can_view_booking_names = False
|
||||||
|
if current_user:
|
||||||
|
try:
|
||||||
|
can_view_booking_names = bool(us.check_admin(current_user) or current_user == appointment_owner)
|
||||||
|
except Exception:
|
||||||
|
can_view_booking_names = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
# Sanitize data for public/client view
|
||||||
|
available_for_view = dict(available)
|
||||||
|
if not can_view_booking_names:
|
||||||
|
sanitized_bookings = []
|
||||||
|
for booking in (available.get('slots_booked') or []):
|
||||||
|
if isinstance(booking, dict):
|
||||||
|
sanitized_bookings.append({'start': booking.get('start', '')})
|
||||||
|
elif isinstance(booking, (list, tuple)) and len(booking) >= 1:
|
||||||
|
sanitized_bookings.append({'start': booking[0]})
|
||||||
|
else:
|
||||||
|
sanitized_bookings.append({'start': ''})
|
||||||
|
available_for_view['slots_booked'] = sanitized_bookings
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
start_daytime = request.form.get('start_day_time')
|
action = request.form.get('action', 'book')
|
||||||
username = request.form.get('client_name')
|
|
||||||
if not start_daytime or not username:
|
|
||||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
|
||||||
return render_template(
|
|
||||||
'termin_client.html',
|
|
||||||
appointment_id=appointment_id,
|
|
||||||
available=available,
|
|
||||||
current_user=session.get('username', ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
if appointment_service.book_slot(appointment_id, start_daytime, username):
|
# Case 1: Admin/Owner cancels a booking
|
||||||
flash('Der Termin wurde gespeichert.', 'success')
|
if action == 'delete' and can_view_booking_names:
|
||||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
slot_time = request.form.get('slot_time')
|
||||||
|
client_name = request.form.get('target_client_name')
|
||||||
|
|
||||||
|
current_slots = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# Keep everything EXCEPT the item targeted for deletion
|
||||||
|
updated_slots = [
|
||||||
|
slot for slot in current_slots
|
||||||
|
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
||||||
|
]
|
||||||
|
|
||||||
|
if termin.update(appointment_id, updated_slots):
|
||||||
|
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Fehler beim Löschen der Buchung.', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
||||||
|
|
||||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
# Case 2: Client books a slot
|
||||||
|
elif action == 'book':
|
||||||
|
start_daytime = request.form.get('start_day_time')
|
||||||
|
username = request.form.get('client_name')
|
||||||
|
custom_answers = tuple(request.form.getlist('custom_answers')) # Cast to tuple for DB safety
|
||||||
|
|
||||||
|
if not start_daytime or not username:
|
||||||
|
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||||
|
return render_template(
|
||||||
|
'termin_client.html',
|
||||||
|
appointment_id=appointment_id,
|
||||||
|
available=available_for_view,
|
||||||
|
current_user=session.get('username', ''),
|
||||||
|
tenant_id=_current_tenant_id(),
|
||||||
|
can_view_booking_names=can_view_booking_names,
|
||||||
|
custom_fields=custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
'terminplaner.client_success',
|
||||||
|
appointment_id=appointment_id,
|
||||||
|
tenant=_current_tenant_id() or None,
|
||||||
|
start=start_daytime,
|
||||||
|
name=username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Der Termin konnte nicht gespeichert werden. Eventuell ist der Slot bereits voll.', 'error')
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_client.html',
|
'termin_client.html',
|
||||||
appointment_id=appointment_id,
|
appointment_id=appointment_id,
|
||||||
available=available,
|
available=available_for_view,
|
||||||
current_user=session.get('username', ''),
|
current_user=session.get('username', ''),
|
||||||
|
tenant_id=_current_tenant_id(),
|
||||||
|
can_view_booking_names=can_view_booking_names,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
appointment_item=appointment_item
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@appoint_bp.route('/client/success/<appointment_id>', methods=['GET'])
|
||||||
|
def client_success(appointment_id):
|
||||||
|
guard = _require_module_enabled()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
slot_start = str(request.args.get('start', '') or '').strip()
|
||||||
|
client_name = str(request.args.get('name', '') or '').strip()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'termin_client_success.html',
|
||||||
|
appointment_id=appointment_id,
|
||||||
|
slot_start=slot_start,
|
||||||
|
client_name=client_name,
|
||||||
|
tenant_id=_current_tenant_id(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@appoint_bp.route('/delete/<appointment_id>', methods=['POST'])
|
||||||
|
def delete_appointment(appointment_id):
|
||||||
|
guard = _require_module_enabled()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
if 'username' not in session:
|
||||||
|
flash('Bitte mit einem Konto anmelden.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
appointment = termin.get_item(appointment_id)
|
||||||
|
if not appointment:
|
||||||
|
return _appointment_not_found_response()
|
||||||
|
|
||||||
|
current_user = str(session.get('username', '')).strip()
|
||||||
|
appointment_user = str(appointment.get('user', '')).strip()
|
||||||
|
if not us.check_admin(current_user) and appointment_user != current_user:
|
||||||
|
flash('Sie dürfen diesen Termin nicht löschen.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
|
||||||
|
|
||||||
|
if termin.remove(appointment_id):
|
||||||
|
flash('Der Terminplan wurde gelöscht.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Der Terminplan konnte nicht gelöscht werden.', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
|
||||||
|
|
||||||
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
||||||
def configure():
|
def configure():
|
||||||
"""
|
"""
|
||||||
Route for authenticated persons to configure a new appointment for them
|
Route for authenticated persons to configure a new appointment schedule
|
||||||
"""
|
"""
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
if guard:
|
if guard:
|
||||||
@@ -69,19 +274,222 @@ def configure():
|
|||||||
end = request.form.get('end_date')
|
end = request.form.get('end_date')
|
||||||
time = request.form.get('time_frame')
|
time = request.form.get('time_frame')
|
||||||
slots_amount = request.form.get('slots_amounts')
|
slots_amount = request.form.get('slots_amounts')
|
||||||
slot_lenght = request.form.get('slot_lenght')
|
slot_length = request.form.get('slot_length')
|
||||||
mail = request.form.get('mail', '')
|
mail = request.form.get('mail', '')
|
||||||
note = request.form.get('note', '')
|
note = request.form.get('note', '')
|
||||||
|
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
custom = request.form.getlist('custom_fields')
|
||||||
|
|
||||||
|
try:
|
||||||
|
clients_p_slot = int(request.form.get('clients_per_slot', 1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
clients_p_slot = 1
|
||||||
|
|
||||||
if not start or not end or not time or not slots_amount or not slot_lenght:
|
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
||||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||||
return render_template('termin_configure.html', school_periods=cfg.SCHOOL_PERIODS, generated_link=None)
|
return render_template(
|
||||||
|
'termin_configure.html',
|
||||||
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
|
generated_link=None,
|
||||||
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the database service function (standardized to match your underlying code)
|
||||||
|
inserted_id = appointment_service.new(
|
||||||
|
date_start=start,
|
||||||
|
date_end=end,
|
||||||
|
time_span=time,
|
||||||
|
slots=slots_amount,
|
||||||
|
slot_length=slot_length,
|
||||||
|
user=session["username"],
|
||||||
|
mail=mail,
|
||||||
|
note=note,
|
||||||
|
calendar_enabled=add_to_calendar,
|
||||||
|
title=title,
|
||||||
|
custom_fields=custom,
|
||||||
|
clients_per_slot=clients_p_slot
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inserted_id:
|
||||||
|
flash('Fehler beim Erstellen des Terminplans.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.configure'))
|
||||||
|
|
||||||
link = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note)
|
|
||||||
flash('Der Terminplan wurde angelegt.', 'success')
|
flash('Der Terminplan wurde angelegt.', 'success')
|
||||||
return render_template('termin_configure.html', school_periods=cfg.SCHOOL_PERIODS, generated_link=link)
|
return render_template(
|
||||||
elif request.method == "GET":
|
'termin_configure.html',
|
||||||
return render_template('termin_configure.html', school_periods=cfg.SCHOOL_PERIODS, generated_link=None)
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
|
generated_link=inserted_id['link'],
|
||||||
|
calendar_link=None,
|
||||||
|
add_to_calendar=add_to_calendar,
|
||||||
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'termin_configure.html',
|
||||||
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
|
generated_link=None,
|
||||||
|
calendar_link=None,
|
||||||
|
add_to_calendar=False,
|
||||||
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
|
title=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
||||||
|
def calendar_export(appointment_id):
|
||||||
|
guard = _require_module_enabled()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
ics_content = appointment_service.build_calendar_ics(appointment_id)
|
||||||
|
if not ics_content:
|
||||||
|
return _appointment_not_found_response()
|
||||||
|
|
||||||
|
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||||
|
|
||||||
|
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{title}-{appointment_id}.ics'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@appoint_bp.route('/client_ics/<appointment_id>.ics', methods=['GET'])
|
||||||
|
def client_slot_calendar_export(appointment_id):
|
||||||
|
guard = _require_module_enabled()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
slot_start = str(request.args.get('start', '') or '').strip()
|
||||||
|
client_name = str(request.args.get('name', '') or '').strip()
|
||||||
|
ics_content = appointment_service.build_client_slot_ics(appointment_id, slot_start, client_name=client_name)
|
||||||
|
if not ics_content:
|
||||||
|
return _appointment_not_found_response()
|
||||||
|
|
||||||
|
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||||
|
|
||||||
|
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET'])
|
||||||
|
def export_pdf_brief(plan_id):
|
||||||
|
# 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an)
|
||||||
|
# terminplan = Terminplan.query.get(plan_id)
|
||||||
|
|
||||||
|
school_info = _get_school_info_for_export()
|
||||||
|
|
||||||
|
school_name = school_info.get('name', 'Schulname')
|
||||||
|
address = school_info.get('address', 'Adresse')
|
||||||
|
postal_code = school_info.get('postal_code', 'PLZ')
|
||||||
|
city = school_info.get('city', 'Stadt')
|
||||||
|
|
||||||
|
schul_daten = {
|
||||||
|
"schulname": school_name,
|
||||||
|
"strasse": address,
|
||||||
|
"plz_ort": f"{postal_code} {city}"
|
||||||
|
}
|
||||||
|
|
||||||
|
plan_daten = {
|
||||||
|
"titel": termin.get_item(plan_id).get('title', 'Terminplan'), # terminplan.title
|
||||||
|
"link": termin.get_item(plan_id).get('link', ''), # terminplan.link
|
||||||
|
"notizen": termin.get_item(plan_id).get('note', '') # terminplan.note
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. QR-Code Bild im temporären Ordner erstellen
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=10, border=0)
|
||||||
|
qr.add_data(plan_daten["link"])
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
|
fd, qr_path = tempfile.mkstemp(suffix=".png")
|
||||||
|
os.close(fd)
|
||||||
|
img.save(qr_path)
|
||||||
|
|
||||||
|
# 3. PDF im Speicher aufbauen (BytesIO)
|
||||||
|
pdf_buffer = io.BytesIO()
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
pdf_buffer,
|
||||||
|
pagesize=A4,
|
||||||
|
rightMargin=2*cm,
|
||||||
|
leftMargin=2.5*cm,
|
||||||
|
topMargin=2.5*cm,
|
||||||
|
bottomMargin=2*cm
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
styles.add(ParagraphStyle(name='Sender', fontSize=8, textColor=grey))
|
||||||
|
styles.add(ParagraphStyle(name='Address', fontSize=10, leading=14))
|
||||||
|
styles.add(ParagraphStyle(name='Date', fontSize=10, alignment=2))
|
||||||
|
styles.add(ParagraphStyle(name='Subject', fontSize=14, fontName='Helvetica-Bold', spaceAfter=16, textColor=HexColor('#0f4c5c')))
|
||||||
|
styles.add(ParagraphStyle(name='Body', fontSize=11, leading=16, spaceAfter=12))
|
||||||
|
styles.add(ParagraphStyle(name='Notes', fontSize=10, leading=14, textColor=HexColor("#444444")))
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
# Absenderzeile
|
||||||
|
sender_text = f"<u>{schul_daten['schulname']} • {schul_daten['strasse']} • {schul_daten['plz_ort']}</u>"
|
||||||
|
elements.append(Paragraph(sender_text, styles['Sender']))
|
||||||
|
elements.append(Spacer(1, 1.5*cm))
|
||||||
|
|
||||||
|
# Sichtfenster-Adresse (Generisch)
|
||||||
|
elements.append(Paragraph("An die<br/>Teilnehmerinnen und Teilnehmer<br/>des Termins", styles['Address']))
|
||||||
|
elements.append(Spacer(1, 2*cm))
|
||||||
|
|
||||||
|
# Datum (Hier statisch zum Test, ggf. dynamisch per datetime)
|
||||||
|
import datetime
|
||||||
|
heute = datetime.datetime.now().strftime("%d.%m.%Y")
|
||||||
|
elements.append(Paragraph(f"München, den {heute}", styles['Date']))
|
||||||
|
elements.append(Spacer(1, 1*cm))
|
||||||
|
|
||||||
|
# Betreff
|
||||||
|
elements.append(Paragraph(f"Einladung zur Terminbuchung: {plan_daten['titel']}", styles['Subject']))
|
||||||
|
|
||||||
|
# Text
|
||||||
|
elements.append(Paragraph("Sehr geehrte Damen und Herren,", styles['Body']))
|
||||||
|
elements.append(Paragraph("hiermit möchten wir Sie herzlich einladen, einen Termin für unsere anstehende Veranstaltung zu buchen. Um den Prozess für alle Beteiligten so einfach und effizient wie möglich zu gestalten, nutzen wir unser Online-Buchungssystem.", styles['Body']))
|
||||||
|
elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
# Link
|
||||||
|
elements.append(Paragraph("<b>Ihr persönlicher Buchungslink:</b>", styles['Body']))
|
||||||
|
link_html = f'<a href="{plan_daten["link"]}" color="#16697a">{plan_daten["link"]}</a>'
|
||||||
|
elements.append(Paragraph(link_html, styles['Body']))
|
||||||
|
|
||||||
|
# Das vorhin erstellte QR-Code Bild einfügen
|
||||||
|
elements.append(Spacer(1, 0.2*cm))
|
||||||
|
elements.append(Image(qr_path, width=3*cm, height=3*cm, hAlign='LEFT'))
|
||||||
|
elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
# Notizen (falls vorhanden)
|
||||||
|
if plan_daten.get('notizen'):
|
||||||
|
elements.append(Paragraph("<b>Zusätzliche Informationen zum Termin:</b>", styles['Body']))
|
||||||
|
elements.append(Paragraph(plan_daten['notizen'], styles['Notes']))
|
||||||
|
|
||||||
|
elements.append(Spacer(1, 1.5*cm))
|
||||||
|
|
||||||
|
# Grußformel
|
||||||
|
elements.append(Paragraph("Mit freundlichen Grüßen,", styles['Body']))
|
||||||
|
elements.append(Spacer(1, 1.5*cm))
|
||||||
|
elements.append(Paragraph(f"<b>{schul_daten['schulname']}</b>", styles['Body']))
|
||||||
|
|
||||||
|
# PDF fertigstellen
|
||||||
|
doc.build(elements)
|
||||||
|
|
||||||
|
# Temporäres Bild löschen
|
||||||
|
if os.path.exists(qr_path):
|
||||||
|
os.remove(qr_path)
|
||||||
|
|
||||||
|
# Buffer auf Anfang zurücksetzen
|
||||||
|
pdf_buffer.seek(0)
|
||||||
|
|
||||||
|
# An Nutzer ausliefern
|
||||||
|
return send_file(
|
||||||
|
pdf_buffer,
|
||||||
|
mimetype='application/pdf',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
@appoint_bp.route('/')
|
@appoint_bp.route('/')
|
||||||
def main():
|
def main():
|
||||||
@@ -89,8 +497,15 @@ def main():
|
|||||||
if guard:
|
if guard:
|
||||||
return guard
|
return guard
|
||||||
|
|
||||||
|
current_user = session.get('username', '')
|
||||||
|
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
|
||||||
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'terminplaner.html',
|
'terminplaner.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
current_user=session.get('username', ''),
|
current_user=current_user,
|
||||||
|
upcoming_events=upcoming_events,
|
||||||
|
tenant_id=tenant_id,
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
flask
|
flask
|
||||||
bson
|
|
||||||
werkzeug
|
werkzeug
|
||||||
gunicorn
|
gunicorn
|
||||||
pymongo
|
pymongo
|
||||||
@@ -16,3 +15,4 @@ openpyxl
|
|||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
pywebpush
|
pywebpush
|
||||||
py-vapid>=1.9.0
|
py-vapid>=1.9.0
|
||||||
|
beautifulsoup4
|
||||||
+30
-33
@@ -1279,7 +1279,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
|
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
|
||||||
@@ -1296,11 +1295,6 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'username' in session %}
|
{% if 'username' in session %}
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<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="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
<li class="nav-item">
|
<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="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
|
<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="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
|
||||||
@@ -1327,10 +1321,7 @@
|
|||||||
{% 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">Bibliotheks-Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
||||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen / Defekte Items</a></li>
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('admin_damaged_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if student_cards_module_enabled %}
|
{% if student_cards_module_enabled %}
|
||||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||||
@@ -1391,31 +1382,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
|
<!-- No navbar for anonymous users on public pages. -->
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand py-0" href="{{ url_for('home') }}">
|
|
||||||
{% set school_logo_thumb = school_info.get('logo_thumb') if school_info else '' %}
|
|
||||||
{% set school_logo_path = school_info.get('logo_path') if school_info else '' %}
|
|
||||||
{% if school_logo_thumb or school_logo_path %}
|
|
||||||
{% if school_logo_thumb %}
|
|
||||||
<img src="{{ url_for('uploaded_file', filename=school_logo_thumb) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
|
|
||||||
{% else %}
|
|
||||||
<img src="{{ url_for('uploaded_file', filename=school_logo_path) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<img src="{{ url_for('static', filename='img/invario-logo.png') }}" alt="Invario" class="invario-logo">
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
@@ -1429,6 +1398,11 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if 'username' not in session %}
|
||||||
|
<div class="guest-impressum-footer">
|
||||||
|
<a href="{{ url_for('impressum') }}">Impressum</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<!-- Cookie consent banner -->
|
<!-- Cookie consent banner -->
|
||||||
<style>
|
<style>
|
||||||
#cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); }
|
#cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); }
|
||||||
@@ -1562,6 +1536,29 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guest-impressum-footer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 1200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-impressum-footer a {
|
||||||
|
pointer-events: auto;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: rgba(15, 23, 42, 0.72);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-impressum-footer a:hover {
|
||||||
|
color: rgba(15, 23, 42, 0.92);
|
||||||
|
border-bottom-color: rgba(15, 23, 42, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
.notification-toast.show {
|
.notification-toast.show {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-50
@@ -10,24 +10,6 @@
|
|||||||
<h1>📚 Bibliothek</h1>
|
<h1>📚 Bibliothek</h1>
|
||||||
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-controls">
|
|
||||||
<!-- Favorites Toggle -->
|
|
||||||
<div class="view-switch">
|
|
||||||
<button id="favoriteToggle" class="toggle-btn" aria-label="Favoriten anzeigen/verbergen">
|
|
||||||
<span class="toggle-icon">⭐</span>
|
|
||||||
<span class="toggle-label">Favoriten</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View Mode Toggle -->
|
|
||||||
<div class="view-switch">
|
|
||||||
<button id="viewModeToggle" class="toggle-btn" aria-label="Ansichtsmodus wechseln">
|
|
||||||
<span class="toggle-icon">ㄷ</span>
|
|
||||||
<span class="toggle-label">Tabelle</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search & Filter Section -->
|
<!-- Search & Filter Section -->
|
||||||
@@ -205,8 +187,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// View mode persistence
|
// View mode persistence
|
||||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||||
@@ -625,50 +606,80 @@ document.getElementById('clearFiltersBtn').addEventListener('click', function()
|
|||||||
closeAllFilters();
|
closeAllFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Favorites toggle
|
|
||||||
document.getElementById('favoriteToggle').addEventListener('click', function() {
|
|
||||||
this.classList.toggle('open');
|
|
||||||
// TODO: Implement favorites filtering
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scanner toggle
|
// Scanner toggle
|
||||||
let scanner = null;
|
// Scanner state tracking
|
||||||
|
let isScanning = false;
|
||||||
|
|
||||||
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const container = document.getElementById('qrContainer');
|
const container = document.getElementById('qrContainer');
|
||||||
const isOpen = container.style.display !== 'none';
|
const isOpen = container.style.display !== 'none';
|
||||||
|
const btn = this;
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
// Close the scanner
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
if (scanner) scanner.clear();
|
Quagga.stop();
|
||||||
this.classList.remove('open');
|
isScanning = false;
|
||||||
this.setAttribute('aria-expanded', 'false');
|
|
||||||
} else {
|
|
||||||
container.style.display = 'block';
|
|
||||||
this.classList.add('open');
|
|
||||||
this.setAttribute('aria-expanded', 'true');
|
|
||||||
|
|
||||||
if (!scanner) {
|
btn.classList.remove('open');
|
||||||
scanner = new Html5Qrcode('qr-reader');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
scanner.start(
|
} else {
|
||||||
{ facingMode: "environment" },
|
// Open the scanner
|
||||||
{ fps: 10, qrbox: 250 },
|
container.style.display = 'block';
|
||||||
onScanSuccess,
|
btn.classList.add('open');
|
||||||
onScanError
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
);
|
|
||||||
}
|
Quagga.init({
|
||||||
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
|
// Targets the element where the video stream will inject
|
||||||
|
target: document.querySelector('#qr-reader'),
|
||||||
|
constraints: {
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
facingMode: "environment" // Forces back camera
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decoder: {
|
||||||
|
// Optimized for 1D barcodes (e.g., student IDs, member cards)
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
alert("Kamera konnte nicht gestartet werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function onScanSuccess(decodedText, decodedResult) {
|
// Single Quagga event listener for successful scans
|
||||||
const studentId = decodedText.trim();
|
Quagga.onDetected(function(data) {
|
||||||
|
const decodedText = data.codeResult.code;
|
||||||
|
const studentId = String(decodedText || '').trim();
|
||||||
|
|
||||||
|
// Stop scanning immediately to prevent multiple triggers
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
|
||||||
|
// Reset UI states to closed
|
||||||
|
const container = document.getElementById('qrContainer');
|
||||||
|
const btn = document.getElementById('scannerBtn');
|
||||||
|
if (container) container.style.display = 'none';
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.remove('open');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route the scanned data to your input
|
||||||
document.getElementById('studentIdInput').value = studentId;
|
document.getElementById('studentIdInput').value = studentId;
|
||||||
alert('Ausweis gescannt: ' + studentId);
|
alert('Ausweis gescannt: ' + studentId);
|
||||||
}
|
});
|
||||||
|
|
||||||
function onScanError(error) {
|
|
||||||
// Silently ignore scan errors
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
+423
-440
File diff suppressed because it is too large
Load Diff
@@ -28,11 +28,10 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
|
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
|
||||||
type="button" role="tab">Rechtsgrundlage</button>
|
type="button" role="tab">Rechtsgrundlage</button>
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
|
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
|
<div class="tab-pane" id="pane-notice" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>NOTICE – Urheberrechtliche Hinweise</h2>
|
<h2>NOTICE – Urheberrechtliche Hinweise</h2>
|
||||||
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
|
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
|
||||||
@@ -59,14 +58,14 @@
|
|||||||
|
|
||||||
<h4>Frontend / Laufzeit-Assets</h4>
|
<h4>Frontend / Laufzeit-Assets</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
|
<li>quagga2 (MIT) © 2014 Christoph Oberhofer, © 2019 Eric Blade and contributors</li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /#pane-notice -->
|
</div><!-- /#pane-notice -->
|
||||||
|
|
||||||
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
|
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-privacy" role="tabpanel">
|
<div class="tab-pane" id="pane-privacy" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Datenschutzerklärung (Privacy Policy)</h2>
|
<h2>Datenschutzerklärung (Privacy Policy)</h2>
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@
|
|||||||
</div><!-- /#pane-privacy -->
|
</div><!-- /#pane-privacy -->
|
||||||
|
|
||||||
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
|
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-security" role="tabpanel">
|
<div class="tab-pane" id="pane-security" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
|
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
|
||||||
|
|
||||||
@@ -129,7 +128,7 @@
|
|||||||
</div><!-- /#pane-security -->
|
</div><!-- /#pane-security -->
|
||||||
|
|
||||||
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
|
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-data" role="tabpanel">
|
<div class="tab-pane" id="pane-data" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
|
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
|
||||||
|
|
||||||
@@ -157,7 +156,7 @@
|
|||||||
</div><!-- /#pane-data -->
|
</div><!-- /#pane-data -->
|
||||||
|
|
||||||
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
|
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-legal" role="tabpanel">
|
<div class="tab-pane" id="pane-legal" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Rechtsgrundlage der Nutzung</h2>
|
<h2>Rechtsgrundlage der Nutzung</h2>
|
||||||
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
|
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
|
||||||
@@ -170,9 +169,6 @@
|
|||||||
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
|
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /#pane-legal -->
|
|
||||||
|
|
||||||
</div><!-- /.tab-content -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+107
-172
@@ -269,23 +269,6 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact appointment badge on cards */
|
|
||||||
.appointment-badge {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background-color: #e1f5fe;
|
|
||||||
border-left: 4px solid #03a9f4;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.appointment-badge .appointment-time {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 4px;
|
|
||||||
color: #01579b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search styles */
|
/* Search styles */
|
||||||
.search-container {
|
.search-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -390,7 +373,7 @@
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Thema:</label>
|
<label>Schlagwort:</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,7 +420,7 @@
|
|||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<span>Unterrichtsfach</span>
|
<span>Unterrichtsfach</span>
|
||||||
<span>Jahrgangsstufe</span>
|
<span>Jahrgangsstufe</span>
|
||||||
<span>Thema</span>
|
<span>Schlagwort</span>
|
||||||
<span>Barcode</span>
|
<span>Barcode</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="items-container" class="items-container">
|
<div id="items-container" class="items-container">
|
||||||
@@ -542,8 +525,7 @@
|
|||||||
|
|
||||||
window.isDebug = false; // Set to true only for development environment
|
window.isDebug = false; // Set to true only for development environment
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// Global state
|
// Global state
|
||||||
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
||||||
@@ -552,51 +534,114 @@
|
|||||||
let codeSearchTerm = '';
|
let codeSearchTerm = '';
|
||||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||||
|
|
||||||
// QR scanner toggle setup
|
// Keep track of the scanner state globally/outer scope
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
let isScanning = false;
|
||||||
const scanButton = document.getElementById('scanButton');
|
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||||
|
|
||||||
|
function startScanner(targetCallback) {
|
||||||
const qrReader = document.getElementById('qr-reader');
|
const qrReader = document.getElementById('qr-reader');
|
||||||
let html5QrcodeScanner = null;
|
const statusText = document.getElementById('status');
|
||||||
|
|
||||||
if (!scanButton || !qrReader) return;
|
// Store the custom action that should happen when a barcode is found
|
||||||
|
activeScannerCallback = targetCallback;
|
||||||
|
|
||||||
const setScannerUi = (isOpen) => {
|
// Show the container
|
||||||
scanButton.classList.toggle('is-active', isOpen);
|
if (qrReader) qrReader.style.display = 'block';
|
||||||
scanButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
if (statusText) statusText.innerText = "Initializing camera...";
|
||||||
scanButton.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
|
||||||
};
|
|
||||||
|
|
||||||
scanButton.addEventListener('click', function() {
|
Quagga.init({
|
||||||
if (qrReader.style.display !== 'block') {
|
inputStream: {
|
||||||
qrReader.style.display = 'block';
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
// Make sure this targets your correct HTML container element
|
||||||
'qr-reader', { fps: 10, qrbox: 250 }
|
target: document.querySelector('#scanner-container'),
|
||||||
);
|
constraints: {
|
||||||
|
width: 640,
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
height: 480,
|
||||||
html5QrcodeScanner.clear();
|
facingMode: "environment" // Force rear camera
|
||||||
qrReader.style.display = 'none';
|
},
|
||||||
|
},
|
||||||
// Put scanned code into the search box and trigger search
|
decoder: {
|
||||||
const searchInput = document.getElementById('code-search');
|
// Your required formats mapped to Quagga2 reader strings
|
||||||
if (searchInput) {
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
searchInput.value = decodedText;
|
}
|
||||||
searchByCode();
|
}, function(err) {
|
||||||
}
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
setScannerUi(false);
|
if (statusText) statusText.innerText = "Camera Error";
|
||||||
});
|
return;
|
||||||
|
|
||||||
setScannerUi(true);
|
|
||||||
} else {
|
|
||||||
if (html5QrcodeScanner) {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
setScannerUi(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
|
||||||
|
// If we are using the main search button, manage its UI state
|
||||||
|
if (!targetCallback) {
|
||||||
|
setScannerUi(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) statusText.innerText = "Scanning...";
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
activeScannerCallback = null; // Reset the callback routing
|
||||||
|
|
||||||
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
if (qrReader) qrReader.style.display = 'none';
|
||||||
|
|
||||||
|
setScannerUi(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScannerUi(isOpen) {
|
||||||
|
const scanBtn = document.getElementById('scanButton');
|
||||||
|
if (!scanBtn) return;
|
||||||
|
|
||||||
|
scanBtn.classList.toggle('is-active', isOpen);
|
||||||
|
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified global reader callback
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
const barcode = String(data.codeResult.code || '').trim();
|
||||||
|
console.log("Barcode detected:", barcode);
|
||||||
|
|
||||||
|
// Keep a local reference to the active callback before shutting down the engine
|
||||||
|
const currentCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||||
|
stopScanner();
|
||||||
|
|
||||||
|
// Route data based on who opened the scanner
|
||||||
|
if (typeof currentCallback === "function") {
|
||||||
|
// Send data directly to the specific edit field's logic
|
||||||
|
currentCallback(barcode);
|
||||||
|
} else {
|
||||||
|
// Fallback Default: Main search box logic
|
||||||
|
const resultText = document.getElementById('barcode-result');
|
||||||
|
if (resultText) resultText.innerText = barcode;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('code-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = barcode;
|
||||||
|
if (typeof searchByCode === "function") {
|
||||||
|
searchByCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Listener for the main standalone search button
|
||||||
|
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||||
|
if (!isScanning) {
|
||||||
|
// Pass null so it defaults to filling the standard search box
|
||||||
|
startScanner(null);
|
||||||
|
} else {
|
||||||
|
stopScanner();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return preferred primary and fallback image URLs based on extension
|
// Return preferred primary and fallback image URLs based on extension
|
||||||
@@ -1006,30 +1051,13 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add appointment badge if item has upcoming appointments
|
|
||||||
let appointmentBadge = '';
|
|
||||||
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
|
|
||||||
const upcomingAppointment = getUpcomingAppointment(item.appointments);
|
|
||||||
if (upcomingAppointment) {
|
|
||||||
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
|
|
||||||
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
|
|
||||||
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
|
|
||||||
|
|
||||||
appointmentBadge = `
|
|
||||||
<div class="appointment-badge">
|
|
||||||
<strong>Geplant für:</strong> ${formattedDate}<br>
|
|
||||||
<span class="appointment-time">${startPeriod}${endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : ''}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-content" data-item-id="${item._id}">
|
<div class="card-content" data-item-id="${item._id}">
|
||||||
<h3 class="item-col-name">${item.Name}</h3>
|
<h3 class="item-col-name">${item.Name}</h3>
|
||||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||||
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||||
@@ -1037,7 +1065,6 @@
|
|||||||
${imagesHtml}
|
${imagesHtml}
|
||||||
</div>
|
</div>
|
||||||
${borrowerBadge}
|
${borrowerBadge}
|
||||||
${appointmentBadge}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
${isAvailableForBorrow && !item.BlockedNow ?
|
${isAvailableForBorrow && !item.BlockedNow ?
|
||||||
@@ -1614,27 +1641,6 @@
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add appointment info panel if item has appointments
|
|
||||||
let appointmentInfoHtml = '';
|
|
||||||
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
|
|
||||||
const upcomingAppointment = getUpcomingAppointment(item.appointments);
|
|
||||||
if (upcomingAppointment) {
|
|
||||||
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
|
|
||||||
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
|
|
||||||
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
|
|
||||||
const timeDisplay = startPeriod + (endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : '');
|
|
||||||
|
|
||||||
appointmentInfoHtml = `
|
|
||||||
<div class="appointment-info-panel">
|
|
||||||
<h4>Geplanter Termin</h4>
|
|
||||||
<p class="appointment-date">Datum: ${formattedDate}</p>
|
|
||||||
<p class="appointment-time">Zeit: ${timeDisplay}</p>
|
|
||||||
${upcomingAppointment.notes ? `<p class="appointment-notes">Notizen: ${upcomingAppointment.notes}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build modal content HTML
|
// Build modal content HTML
|
||||||
modalContent.innerHTML = `
|
modalContent.innerHTML = `
|
||||||
<h2>${item.Name}</h2>
|
<h2>${item.Name}</h2>
|
||||||
@@ -1642,7 +1648,6 @@
|
|||||||
<button type="button" class="bookmark-btn" id="modal-bookmark-btn" title="Merken">${(window.currentFavorites||new Set()).has(item._id) ? '★' : '☆'}</button>
|
<button type="button" class="bookmark-btn" id="modal-bookmark-btn" title="Merken">${(window.currentFavorites||new Set()).has(item._id) ? '★' : '☆'}</button>
|
||||||
</div>
|
</div>
|
||||||
${borrowerInfoHtml}
|
${borrowerInfoHtml}
|
||||||
${appointmentInfoHtml}
|
|
||||||
|
|
||||||
<div class="modal-image-container">
|
<div class="modal-image-container">
|
||||||
${imagesHtml}
|
${imagesHtml}
|
||||||
@@ -1677,7 +1682,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Thema:</div>
|
<div class="detail-label">Schlagwort:</div>
|
||||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2804,66 +2809,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for appointment display
|
|
||||||
function formatAppointmentDate(dateString) {
|
|
||||||
if (!dateString) return '';
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAppointmentPeriod(period) {
|
|
||||||
if (!period) return '';
|
|
||||||
const periodMap = {
|
|
||||||
'1': '1. Stunde',
|
|
||||||
'2': '2. Stunde',
|
|
||||||
'3': '3. Stunde',
|
|
||||||
'4': '4. Stunde',
|
|
||||||
'5': '5. Stunde',
|
|
||||||
'6': '6. Stunde',
|
|
||||||
'7': '7. Stunde',
|
|
||||||
'8': '8. Stunde',
|
|
||||||
'9': '9. Stunde',
|
|
||||||
'10': '10. Stunde',
|
|
||||||
'pause1': '1. Pause',
|
|
||||||
'pause2': '2. Pause',
|
|
||||||
'mittagspause': 'Mittagspause'
|
|
||||||
};
|
|
||||||
return periodMap[period] || period;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUpcomingAppointment(appointments) {
|
|
||||||
if (!appointments || !Array.isArray(appointments) || appointments.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const today = now.toDateString();
|
|
||||||
|
|
||||||
// Filter appointments that are today or in the future
|
|
||||||
const futureAppointments = appointments.filter(apt => {
|
|
||||||
try {
|
|
||||||
const aptDate = new Date(apt.date);
|
|
||||||
return aptDate.toDateString() === today || aptDate >= now.setHours(0, 0, 0, 0);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (futureAppointments.length === 0) return null;
|
|
||||||
|
|
||||||
// Sort by date and return the earliest
|
|
||||||
futureAppointments.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
||||||
return futureAppointments[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateForInput(dateString) {
|
function formatDateForInput(dateString) {
|
||||||
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
|
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@@ -3947,16 +3892,6 @@
|
|||||||
.borrower-info-panel h4 { color: #856404; margin: 0 0 10px 0; font-size: 1.1rem; }
|
.borrower-info-panel h4 { color: #856404; margin: 0 0 10px 0; font-size: 1.1rem; }
|
||||||
.borrower-name, .borrow-time { margin: 5px 0; color: #856404; font-weight: 500; }
|
.borrower-name, .borrow-time { margin: 5px 0; color: #856404; font-weight: 500; }
|
||||||
|
|
||||||
.appointment-info-panel {
|
|
||||||
background-color: #e1f5fe;
|
|
||||||
border: 1px solid #81d4fa;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.appointment-info-panel h4 { color: #0277bd; margin: 0 0 10px 0; font-size: 1.1rem; }
|
|
||||||
.appointment-date, .appointment-time, .appointment-user { margin: 5px 0; color: #0277bd; font-weight: 500; }
|
|
||||||
|
|
||||||
/* Modal Details Styling (text formatting) */
|
/* Modal Details Styling (text formatting) */
|
||||||
.modal-details { margin: 20px 0; }
|
.modal-details { margin: 20px 0; }
|
||||||
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
|
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
|
||||||
|
|||||||
+129
-80
@@ -2395,7 +2395,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Thema:</label>
|
<label>Schlagwort:</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2435,7 +2435,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
<div class="qr-container">
|
<div class="qr-container">
|
||||||
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
|
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
|
||||||
<div id="qr-reader"></div>
|
<div id="qr-reader" style="display: none;">
|
||||||
|
<h3>Status: <span id="status">Bereit</span></h3>
|
||||||
|
<div id="scanner-container"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
||||||
@@ -2443,7 +2446,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<span>Unterrichtsfach</span>
|
<span>Unterrichtsfach</span>
|
||||||
<span>Jahrgangsstufe</span>
|
<span>Jahrgangsstufe</span>
|
||||||
<span>Thema</span>
|
<span>Schlagwort</span>
|
||||||
<span>Barcode</span>
|
<span>Barcode</span>
|
||||||
<span>Anzahl</span>
|
<span>Anzahl</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2564,7 +2567,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Thema:</h3>
|
<h3>Schlagwort:</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-filter3-1">Wert 1:</label>
|
<label for="edit-filter3-1">Wert 1:</label>
|
||||||
@@ -2729,7 +2732,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
window.isDebug = false; // Set to true only for development environment
|
window.isDebug = false; // Set to true only for development environment
|
||||||
</script>
|
</script>
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Function to check if a file is a video
|
// Function to check if a file is a video
|
||||||
function isVideoFile(filename) {
|
function isVideoFile(filename) {
|
||||||
@@ -2739,7 +2742,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize QR Code scanner and global variables
|
// Initialize QR Code scanner and global variables
|
||||||
let html5QrcodeScanner = null;
|
|
||||||
let codeSearchTerm = '';
|
let codeSearchTerm = '';
|
||||||
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 = '';
|
||||||
@@ -2768,51 +2770,114 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
// No-op in production
|
// No-op in production
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('scanButton').addEventListener('click', function() {
|
|
||||||
const qrReader = document.getElementById('qr-reader');
|
|
||||||
const scanBtn = document.getElementById('scanButton');
|
|
||||||
|
|
||||||
const setScannerUi = (isOpen) => {
|
// Keep track of the scanner state globally/outer scope
|
||||||
if (!scanBtn) return;
|
let isScanning = false;
|
||||||
scanBtn.classList.toggle('is-active', isOpen);
|
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
||||||
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
function startScanner(targetCallback) {
|
||||||
};
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
const statusText = document.getElementById('status');
|
||||||
if (qrReader.style.display === 'none') {
|
|
||||||
qrReader.style.display = 'block';
|
// Store the custom action that should happen when a barcode is found
|
||||||
|
activeScannerCallback = targetCallback;
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
|
||||||
"qr-reader", {
|
// Show the container
|
||||||
fps: 10,
|
if (qrReader) qrReader.style.display = 'block';
|
||||||
qrbox: 250,
|
if (statusText) statusText.innerText = "Initializing camera...";
|
||||||
rememberLastUsedCamera: true
|
|
||||||
}
|
Quagga.init({
|
||||||
);
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
type: "LiveStream",
|
||||||
html5QrcodeScanner.clear();
|
// Make sure this targets your correct HTML container element
|
||||||
qrReader.style.display = 'none';
|
target: document.querySelector('#scanner-container'),
|
||||||
|
constraints: {
|
||||||
// Instead of navigating to the URL, put the scanned code in the search box
|
width: 640,
|
||||||
const searchInput = document.getElementById('code-search');
|
height: 480,
|
||||||
if (searchInput) {
|
facingMode: "environment" // Force rear camera
|
||||||
searchInput.value = decodedText;
|
},
|
||||||
// Trigger search automatically
|
},
|
||||||
|
decoder: {
|
||||||
|
// Your required formats mapped to Quagga2 reader strings
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
if (statusText) statusText.innerText = "Camera Error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
|
||||||
|
// If we are using the main search button, manage its UI state
|
||||||
|
if (!targetCallback) {
|
||||||
|
setScannerUi(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) statusText.innerText = "Scanning...";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
activeScannerCallback = null; // Reset the callback routing
|
||||||
|
|
||||||
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
if (qrReader) qrReader.style.display = 'none';
|
||||||
|
|
||||||
|
setScannerUi(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScannerUi(isOpen) {
|
||||||
|
const scanBtn = document.getElementById('scanButton');
|
||||||
|
if (!scanBtn) return;
|
||||||
|
|
||||||
|
scanBtn.classList.toggle('is-active', isOpen);
|
||||||
|
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified global reader callback
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
const barcode = String(data.codeResult.code || '').trim();
|
||||||
|
console.log("Barcode detected:", barcode);
|
||||||
|
|
||||||
|
// Keep a local reference to the active callback before shutting down the engine
|
||||||
|
const currentCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||||
|
stopScanner();
|
||||||
|
|
||||||
|
// Route data based on who opened the scanner
|
||||||
|
if (typeof currentCallback === "function") {
|
||||||
|
// Send data directly to the specific edit field's logic
|
||||||
|
currentCallback(barcode);
|
||||||
|
} else {
|
||||||
|
// Fallback Default: Main search box logic
|
||||||
|
const resultText = document.getElementById('barcode-result');
|
||||||
|
if (resultText) resultText.innerText = barcode;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('code-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = barcode;
|
||||||
|
if (typeof searchByCode === "function") {
|
||||||
searchByCode();
|
searchByCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
setScannerUi(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
setScannerUi(true);
|
|
||||||
} else {
|
|
||||||
if (html5QrcodeScanner) {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
}
|
}
|
||||||
qrReader.style.display = 'none';
|
}
|
||||||
setScannerUi(false);
|
});
|
||||||
|
|
||||||
|
// Event Listener for the main standalone search button
|
||||||
|
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||||
|
if (!isScanning) {
|
||||||
|
// Pass null so it defaults to filling the standard search box
|
||||||
|
startScanner(null);
|
||||||
|
} else {
|
||||||
|
stopScanner();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2824,26 +2889,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qrReader.style.display !== 'none') {
|
// Toggle close if it's already running
|
||||||
if (html5QrcodeScanner) {
|
if (isScanning && qrReader.style.display !== 'none') {
|
||||||
html5QrcodeScanner.clear();
|
stopScanner();
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
scanEditBtn.textContent = 'Barcode scannen';
|
scanEditBtn.textContent = 'Barcode scannen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrReader.style.display = 'block';
|
|
||||||
scanEditBtn.textContent = 'Scanner schließen';
|
scanEditBtn.textContent = 'Scanner schließen';
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
|
||||||
fps: 10,
|
// Start scanner with custom logic mapping to the Code input field
|
||||||
qrbox: 250,
|
startScanner(function(decodedText) {
|
||||||
rememberLastUsedCamera: true
|
editCodeInput.value = decodedText;
|
||||||
});
|
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
editCodeInput.value = String(decodedText || '').trim();
|
|
||||||
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
||||||
scanEditBtn.textContent = 'Barcode scannen';
|
scanEditBtn.textContent = 'Barcode scannen';
|
||||||
});
|
});
|
||||||
@@ -2857,26 +2914,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qrReader.style.display !== 'none') {
|
// Toggle close if it's already running
|
||||||
if (html5QrcodeScanner) {
|
if (isScanning && qrReader.style.display !== 'none') {
|
||||||
html5QrcodeScanner.clear();
|
stopScanner();
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrReader.style.display = 'block';
|
|
||||||
scanIsbnBtn.textContent = 'Scanner schließen';
|
scanIsbnBtn.textContent = 'Scanner schließen';
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
|
||||||
fps: 10,
|
// Start scanner with custom logic mapping to the ISBN input field
|
||||||
qrbox: 250,
|
startScanner(function(decodedText) {
|
||||||
rememberLastUsedCamera: true
|
editIsbnInput.value = decodedText;
|
||||||
});
|
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
editIsbnInput.value = String(decodedText || '').trim();
|
|
||||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||||
if (typeof fetchBookInfo === 'function') {
|
if (typeof fetchBookInfo === 'function') {
|
||||||
fetchBookInfo('edit');
|
fetchBookInfo('edit');
|
||||||
@@ -3637,7 +3686,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||||
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
|
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
|
||||||
@@ -4180,7 +4229,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
|
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
|
||||||
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
|
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
|
||||||
|
|
||||||
// Fill in filter 3 (Thema)
|
// Fill in filter 3 (Schlagwort)
|
||||||
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
|
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
|
||||||
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
||||||
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
||||||
@@ -4494,7 +4543,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Thema:</div>
|
<div class="detail-label">Schlagwort:</div>
|
||||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Wartungsarbeiten - Inventarsystem{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8 col-xl-7">
|
||||||
|
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||||
|
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||||
|
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Systemstatus</p>
|
||||||
|
<h1 class="h3 mb-0 fw-bold">Wartungsarbeiten</h1>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4 p-md-5 bg-white text-center">
|
||||||
|
<div class="display-3 fw-bold text-warning mb-3">500</div>
|
||||||
|
<p class="lead mb-2">Das System führt gerade Wartungsarbeiten durch.</p>
|
||||||
|
<p class="text-muted mb-4">Bitte versuchen Sie es in wenigen Minuten erneut. Falls das Problem bestehen bleibt, informieren Sie bitte die Administration.</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
|
||||||
|
<a href="{{ url_for('login') }}" class="btn btn-primary btn-lg">Zum Login</a>
|
||||||
|
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,18 +2,56 @@
|
|||||||
|
|
||||||
{% block title %}Termin buchen - Inventarsystem{% endblock %}
|
{% block title %}Termin buchen - Inventarsystem{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
#client-slot-calendar {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
.fc .fc-timegrid-slot-label-cushion,
|
||||||
|
.fc .fc-timegrid-axis-cushion,
|
||||||
|
.fc .fc-col-header-cell-cushion {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.slot-selected-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(13, 110, 253, 0.1);
|
||||||
|
color: #0d6efd;
|
||||||
|
padding: .35rem .75rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.day-slider-wrap {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: .9rem;
|
||||||
|
padding: .8rem 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12 col-xl-10">
|
<div class="col-12 col-xxl-11">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-12 col-lg-5">
|
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||||
<div class="card-body p-4 p-md-5">
|
<div class="card-body p-4 p-md-5">
|
||||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||||
<h1 class="h3 fw-bold mb-3">Termin buchen</h1>
|
{% if can_view_booking_names %}
|
||||||
<p class="text-muted mb-4">Wählen Sie einen freien Zeitpunkt und tragen Sie Ihren Namen ein. Der Termin wird anschließend im Plan gespeichert.</p>
|
<h1 class="h3 fw-bold mb-3">Termin Übersicht - {{ available.title }}</h1>
|
||||||
|
<p class="text-muted mb-4">Hier können Sie alle Buchungen, die bereits eingegangen sind, einsehen und verwalten.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
|
||||||
|
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="p-3 rounded-3 bg-light mb-3">
|
<div class="p-3 rounded-3 bg-light mb-3">
|
||||||
<div class="fw-semibold">Zeitraum</div>
|
<div class="fw-semibold">Zeitraum</div>
|
||||||
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
||||||
@@ -26,13 +64,26 @@
|
|||||||
<div class="fw-semibold">Slot-Länge</div>
|
<div class="fw-semibold">Slot-Länge</div>
|
||||||
<div>{{ available.slot_lenght }} Minuten</div>
|
<div>{{ available.slot_lenght }} Minuten</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_view_booking_names %}
|
||||||
|
<div class="p-3 rounded-3 bg-light mb-3">
|
||||||
|
<div class="fw-semibold mb-2">Gewählter Termin</div>
|
||||||
|
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if available.slots_booked %}
|
{% if available.slots_booked %}
|
||||||
<div class="p-3 rounded-3 bg-light">
|
<div class="p-3 rounded-3 bg-light">
|
||||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||||
<ul class="mb-0 small">
|
<ul class="mb-0 small text-muted">
|
||||||
{% for booking in available.slots_booked %}
|
{% for booking in available.slots_booked %}
|
||||||
<li>{{ booking.start }}{% if booking.name %} - {{ booking.name }}{% endif %}</li>
|
<li>
|
||||||
|
{% if booking is mapping %}
|
||||||
|
{{ booking.get('start', '') }} - Belegt
|
||||||
|
{% else %}
|
||||||
|
{{ booking[0] }} - {% if can_view_booking_names %}<span class="fw-semibold text-dark">{{ booking[1] }}</span>{% else %}Belegt{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,30 +91,588 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-7">
|
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2>
|
|
||||||
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id) }}" class="vstack gap-3">
|
{% if can_view_booking_names %}
|
||||||
<div>
|
<!-- ADMIN / AUTOREN ANSICHT -->
|
||||||
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||||
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="2026-05-29 10:30" required>
|
<div>
|
||||||
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div>
|
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
|
||||||
|
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-self-start align-self-md-center">
|
||||||
|
<!-- Neuer CSV Export Button -->
|
||||||
|
<a href="{{ url_for('terminplaner.export_csv', appointment_id=appointment_id) }}" class="btn btn-outline-success rounded-3 d-inline-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v2.5a.5.5 0 0 1 .5-.5"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/>
|
||||||
|
</svg>
|
||||||
|
Excel / CSV Export
|
||||||
|
</a>
|
||||||
|
<span class="badge bg-primary rounded-pill fs-6 px-3 d-flex align-items-center">
|
||||||
|
{{ available.slots_total - available.slots_left }} von {{ available.slots_total }} belegt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="bg-light p-3 rounded-3 mb-4">
|
||||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
<div class="row g-2 align-items-center">
|
||||||
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
<div class="col-12 col-md-8">
|
||||||
|
<label for="table-search" class="form-label small fw-semibold text-muted mb-1">Live-Filter (Name, Uhrzeit, Zusatzfelder...)</label>
|
||||||
|
<input type="text" id="table-search" class="form-control form-control-lg bg-white border" placeholder="Suchbegriff eingeben...">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 d-flex align-items-end h-100 mt-md-4">
|
||||||
|
<button type="button" id="clear-filter-btn" class="btn btn-outline-secondary w-100 btn-lg">Filter zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
{% if available.slots_booked %}
|
||||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle border-top" id="admin-booking-table">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3">Zeitpunkt</th>
|
||||||
|
<th scope="col" class="py-3">Name</th>
|
||||||
|
<!-- Spaltenüberschriften für Custom Fields -->
|
||||||
|
{% for field_label in custom_fields %}
|
||||||
|
<th scope="col" class="py-3">{{ field_label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th scope="col" class="py-3 text-end">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="booking-table-body">
|
||||||
|
{# Wir loopen jetzt über die rohen DB-Daten statt über die beschnittene Service-Funktion #}
|
||||||
|
{% for booking in appointment_item.get('slots_booked', []) %}
|
||||||
|
<tr>
|
||||||
|
<!-- 1. ZEITPUNKT -->
|
||||||
|
<td class="fw-semibold text-primary py-3 text-nowrap">
|
||||||
|
{{ booking[0] }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 2. CLIENT NAME -->
|
||||||
|
<td>
|
||||||
|
<span class="fw-bold text-dark">{{ booking[1] }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 3. CUSTOM FIELDS (Präziser Index-Abgleich gegen Spaltenkopf) -->
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
<td>
|
||||||
|
{# Prüft ob für dieses Feld eine Antwort am exakt gleichen Index existiert #}
|
||||||
|
{% if booking|length > 2 and booking[2] and booking[2]|length > loop.index0 %}
|
||||||
|
{% set answer = booking[2][loop.index0] %}
|
||||||
|
{{ answer if answer else '<span class="text-muted small">-</span>'|safe }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- 4. AKTION (LÖSCHEN) -->
|
||||||
|
<td class="text-end">
|
||||||
|
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" onsubmit="return confirm('Möchten Sie diesen Termin wirklich löschen?');" class="d-inline">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="slot_time" value="{{ booking[0] }}">
|
||||||
|
<input type="hidden" name="target_client_name" value="{{ booking[1] }}">
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger px-3 rounded-3">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div id="no-results-msg" class="text-center py-4 text-muted d-none">
|
||||||
|
Keine Buchungen entsprechen Ihrem Filter.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<p class="mb-0">Bisher sind noch keine Buchungen für diesen Terminplaner eingegangen.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="pt-4 border-top mt-4">
|
||||||
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||||
|
|
||||||
|
<div class="day-slider-wrap mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
||||||
|
<span class="small text-muted">Tag wählen</span>
|
||||||
|
<strong id="day-slider-label" class="small"></strong>
|
||||||
|
</div>
|
||||||
|
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="client-slot-calendar" class="mb-4"></div>
|
||||||
|
|
||||||
|
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
||||||
|
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
|
||||||
|
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
||||||
|
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if custom_fields %}
|
||||||
|
{% for field_label in custom_fields %}
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold">{{ field_label }}</label>
|
||||||
|
<input type="text" name="custom_answers" class="form-control form-control-lg" placeholder="{{ field_label }} eingeben" required>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||||
|
<input type="hidden" name="action" value="book">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="calendarDownloadModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Termin zum Kalender hinzufügen?</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="mb-2">Sie haben einen Termin ausgewählt.</p>
|
||||||
|
<p class="mb-0 small text-muted">Mit einem Klick auf ".ics herunterladen" können Sie den Termin in Apple/Google/Outlook-Kalender importieren.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button id="confirm-booking-only" type="button" class="btn btn-primary">Jetzt buchen</button>
|
||||||
|
<button id="confirm-booking-with-ics" type="button" class="btn btn-success">Buchen + .ics</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const searchInput = document.getElementById('table-search');
|
||||||
|
const clearBtn = document.getElementById('clear-filter-btn');
|
||||||
|
const tableBody = document.getElementById('booking-table-body');
|
||||||
|
const noResultsMsg = document.getElementById('no-results-msg');
|
||||||
|
|
||||||
|
if (searchInput && tableBody) {
|
||||||
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
const query = this.value.toLowerCase().trim();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
// Liest den Textinhalt aller Spalten der Zeile aus
|
||||||
|
const rowText = row.textContent.toLowerCase();
|
||||||
|
|
||||||
|
if (rowText.includes(query)) {
|
||||||
|
row.classList.remove('d-none');
|
||||||
|
visibleRowsCount++;
|
||||||
|
} else {
|
||||||
|
row.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Falls kein Filterergebnis gefunden wurde, Nachricht einblenden
|
||||||
|
if (visibleRowsCount === 0 && query !== '') {
|
||||||
|
noResultsMsg.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
noResultsMsg.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter zurücksetzen Button
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
searchInput.value = '';
|
||||||
|
rows.forEach(row => row.classList.remove('d-none'));
|
||||||
|
noResultsMsg.classList.add('d-none');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const available = {{ available|tojson }};
|
||||||
|
const appointmentId = {{ appointment_id|tojson }};
|
||||||
|
const canViewBookingNames = {{ can_view_booking_names|tojson }}; // Injected from Flask
|
||||||
|
const slider = document.getElementById('day-slider');
|
||||||
|
const sliderLabel = document.getElementById('day-slider-label');
|
||||||
|
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
||||||
|
const selectedSlotInput = document.getElementById('start_day_time');
|
||||||
|
const selectedSlotBadge = document.getElementById('selected-slot-badge');
|
||||||
|
const clientNameInput = document.getElementById('client_name');
|
||||||
|
const form = document.getElementById('client-booking-form');
|
||||||
|
const calendarEl = document.getElementById('client-slot-calendar');
|
||||||
|
const modalEl = document.getElementById('calendarDownloadModal');
|
||||||
|
const confirmBookingOnlyBtn = document.getElementById('confirm-booking-only');
|
||||||
|
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
||||||
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
|
||||||
|
// Fallback for both spellings of slot length
|
||||||
|
const slotLength = Number.parseInt(available.slot_length || available.slot_lenght, 10) || 45;
|
||||||
|
const clientsPerSlot = Number.parseInt(available.clients_per_slot, 10) || 1;
|
||||||
|
const bookingsByTime = available.bookings_by_time || {};
|
||||||
|
|
||||||
|
function formatDateForInput(dateObj) {
|
||||||
|
const y = dateObj.getFullYear();
|
||||||
|
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(dateObj.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(dateObj.getMinutes()).padStart(2, '0');
|
||||||
|
return y + '-' + m + '-' + d + ' ' + hh + ':' + mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateReadable(value) {
|
||||||
|
if (!value) return 'Noch kein Slot ausgewählt.';
|
||||||
|
return 'Ausgewählt: ' + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateRangeInclusive(startStr, endStr) {
|
||||||
|
const days = [];
|
||||||
|
const start = new Date(startStr + 'T00:00:00');
|
||||||
|
const end = new Date(endStr + 'T00:00:00');
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
const cursor = new Date(start);
|
||||||
|
while (cursor <= end) {
|
||||||
|
const y = cursor.getFullYear();
|
||||||
|
const m = String(cursor.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(cursor.getDate()).padStart(2, '0');
|
||||||
|
days.push(y + '-' + m + '-' + d);
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMinutes(dateObj, minutes) {
|
||||||
|
return new Date(dateObj.getTime() + minutes * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeForCalendar(dateObj) {
|
||||||
|
return String(dateObj.getHours()).padStart(2, '0') + ':' + String(dateObj.getMinutes()).padStart(2, '0') + ':00';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateStr, days) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return y + '-' + m + '-' + day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeSpanEntry(entry) {
|
||||||
|
const value = String(entry || '').trim();
|
||||||
|
let m = value.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$/);
|
||||||
|
if (m) {
|
||||||
|
return { date: m[1], from: m[2], to: m[3] };
|
||||||
|
}
|
||||||
|
m = value.match(/^(\d{2}:\d{2})-(\d{2}:\d{2})$/);
|
||||||
|
if (m) {
|
||||||
|
return { date: null, from: m[1], to: m[2] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullyBookedSlots = [];
|
||||||
|
|
||||||
|
function buildCandidateSlots() {
|
||||||
|
const slots = [];
|
||||||
|
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
|
const spans = Array.isArray(available.time_span) ? available.time_span : [];
|
||||||
|
|
||||||
|
spans.forEach(function (entry) {
|
||||||
|
const parsed = parseTimeSpanEntry(entry);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
||||||
|
targetDates.forEach(function (date) {
|
||||||
|
const from = new Date(date + 'T' + parsed.from + ':00');
|
||||||
|
const to = new Date(date + 'T' + parsed.to + ':00');
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = new Date(from);
|
||||||
|
while (addMinutes(cursor, slotLength) <= to) {
|
||||||
|
const slotStart = formatDateForInput(cursor);
|
||||||
|
const currentBookingsCount = bookingsByTime[slotStart] || 0;
|
||||||
|
|
||||||
|
// Check if the current individual timestamp still has capacity left
|
||||||
|
if (currentBookingsCount < clientsPerSlot) {
|
||||||
|
slots.push({
|
||||||
|
start: slotStart,
|
||||||
|
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
||||||
|
bookingsCount: currentBookingsCount
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fullyBookedSlots.push({
|
||||||
|
start: slotStart,
|
||||||
|
end: formatDateForInput(addMinutes(cursor, slotLength))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor = addMinutes(cursor, slotLength);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateSlots = buildCandidateSlots();
|
||||||
|
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
||||||
|
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
|
|
||||||
|
let slotMinTime = '08:00:00';
|
||||||
|
let slotMaxTime = '20:00:00';
|
||||||
|
|
||||||
|
const visibleStart = allDays[0] || String(available.date_start || '');
|
||||||
|
const visibleEndExclusive = allDays.length > 0
|
||||||
|
? addDays(allDays[allDays.length - 1], 1)
|
||||||
|
: addDays(String(available.date_end || available.date_start || ''), 1);
|
||||||
|
|
||||||
|
const multiDayDuration = Math.max(1, allDays.length || 1);
|
||||||
|
const initialViewName = multiDayDuration === 1 ? 'timeGridDay' : 'timeGridRange';
|
||||||
|
let selectedSlot = '';
|
||||||
|
let selectedEvent = null;
|
||||||
|
let allowImmediateSubmit = false;
|
||||||
|
|
||||||
|
const calendar = new FullCalendar.Calendar(calendarEl, {
|
||||||
|
initialView: initialViewName,
|
||||||
|
views: {
|
||||||
|
timeGridRange: {
|
||||||
|
type: 'timeGrid',
|
||||||
|
duration: { days: multiDayDuration },
|
||||||
|
buttonText: 'Zeitraum'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locale: 'de',
|
||||||
|
firstDay: 1,
|
||||||
|
height: 'auto',
|
||||||
|
allDaySlot: false,
|
||||||
|
editable: true,
|
||||||
|
eventStartEditable: true,
|
||||||
|
eventDurationEditable: false,
|
||||||
|
selectable: false,
|
||||||
|
slotDuration: '00:15:00',
|
||||||
|
snapDuration: '00:15:00',
|
||||||
|
slotMinTime: slotMinTime,
|
||||||
|
slotMaxTime: slotMaxTime,
|
||||||
|
nowIndicator: true,
|
||||||
|
validRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
|
visibleRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
|
headerToolbar: {
|
||||||
|
left: '',
|
||||||
|
center: 'title',
|
||||||
|
right: multiDayDuration === 1 ? '' : 'timeGridDay,timeGridRange'
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
eventDrop: function (info) {
|
||||||
|
if (info.event.id !== 'selected-slot') return;
|
||||||
|
const droppedStart = formatDateForInput(info.event.start);
|
||||||
|
if (!slotStartSet.has(droppedStart)) {
|
||||||
|
info.revert();
|
||||||
|
window.alert('Dieser Zeitpunkt ist ausgebucht oder nicht verfügbar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applySelectedSlot(droppedStart);
|
||||||
|
},
|
||||||
|
eventClick: function (info) {
|
||||||
|
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
||||||
|
if (slotType !== 'free') return;
|
||||||
|
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSliderLabel() {
|
||||||
|
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
|
sliderLabel.textContent = dateList[idx] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelectedSlot(value) {
|
||||||
|
selectedSlot = String(value || '').trim();
|
||||||
|
selectedSlotInput.value = selectedSlot;
|
||||||
|
selectedSlotBadge.innerHTML = selectedSlot
|
||||||
|
? '<span class="slot-selected-chip">' + selectedSlot + '</span>'
|
||||||
|
: 'Noch kein Slot ausgewählt.';
|
||||||
|
|
||||||
|
if (selectedEvent) {
|
||||||
|
selectedEvent.remove();
|
||||||
|
selectedEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedSlot) return;
|
||||||
|
|
||||||
|
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
||||||
|
const end = addMinutes(start, slotLength);
|
||||||
|
selectedEvent = calendar.addEvent({
|
||||||
|
id: 'selected-slot',
|
||||||
|
title: 'Ihr ausgewählter Termin',
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
color: '#0d6efd',
|
||||||
|
editable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshIcsLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcsDownloadUrl() {
|
||||||
|
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
||||||
|
const url = new URL(base, window.location.origin);
|
||||||
|
if (selectedSlot) url.searchParams.set('start', selectedSlot);
|
||||||
|
const name = String(clientNameInput.value || '').trim();
|
||||||
|
if (name) url.searchParams.set('name', name);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshIcsLink() {
|
||||||
|
const icsUrl = getIcsDownloadUrl();
|
||||||
|
if (confirmBookingWithIcsBtn) {
|
||||||
|
confirmBookingWithIcsBtn.setAttribute('data-ics-url', icsUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Active/Available Slots with Live Seat Counts
|
||||||
|
candidateSlots.forEach(function (slot) {
|
||||||
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
|
|
||||||
|
let displayTitle = 'Freier Slot';
|
||||||
|
if (clientsPerSlot > 1) {
|
||||||
|
const remainingSeats = clientsPerSlot - slot.bookingsCount;
|
||||||
|
displayTitle += ' (' + remainingSeats + '/' + clientsPerSlot + ' frei)';
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.addEvent({
|
||||||
|
title: displayTitle,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
color: '#0d6efd',
|
||||||
|
textColor: '#ffffff',
|
||||||
|
editable: false,
|
||||||
|
extendedProps: {
|
||||||
|
slotStart: slot.start,
|
||||||
|
slotType: 'free',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client View Optimization: Render solid red events ONLY if a slot is 100% full
|
||||||
|
fullyBookedSlots.forEach(function (slot) {
|
||||||
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
|
calendar.addEvent({
|
||||||
|
title: 'Ausgebucht',
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
color: '#dc3545',
|
||||||
|
editable: false,
|
||||||
|
display: 'block'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin Management View: Render detailed name blocks exclusively for management interaction
|
||||||
|
if (canViewBookingNames) {
|
||||||
|
(available.slots_booked || []).forEach(function (booking) {
|
||||||
|
const startStr = String(booking.start || '').trim();
|
||||||
|
if (!startStr) return;
|
||||||
|
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
||||||
|
const end = addMinutes(start, slotLength);
|
||||||
|
calendar.addEvent({
|
||||||
|
title: 'Belegt: ' + (booking.name || 'Anonym'),
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
color: '#9e9e9e',
|
||||||
|
editable: false,
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners and Setup
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
if (!selectedSlotInput.value) {
|
||||||
|
ev.preventDefault();
|
||||||
|
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allowImmediateSubmit) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
if (modal) modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmBookingOnlyBtn) {
|
||||||
|
confirmBookingOnlyBtn.addEventListener('click', function () {
|
||||||
|
allowImmediateSubmit = true;
|
||||||
|
if (modal) modal.hide();
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmBookingWithIcsBtn) {
|
||||||
|
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
||||||
|
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
||||||
|
if (icsUrl) window.open(icsUrl, '_blank');
|
||||||
|
allowImmediateSubmit = true;
|
||||||
|
if (modal) modal.hide();
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clientNameInput.addEventListener('input', refreshIcsLink);
|
||||||
|
|
||||||
|
if (sliderWrap) {
|
||||||
|
sliderWrap.style.display = multiDayDuration > 1 ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
slider.max = String(Math.max(0, allDays.length - 1));
|
||||||
|
slider.value = '0';
|
||||||
|
updateSliderLabel();
|
||||||
|
if (allDays.length > 0) {
|
||||||
|
calendar.gotoDate(allDays[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
slider.addEventListener('input', function () {
|
||||||
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
|
updateSliderLabel();
|
||||||
|
if (allDays[idx] && calendar.view.type === 'timeGridDay') {
|
||||||
|
calendar.gotoDate(allDays[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
calendar.render();
|
||||||
|
refreshIcsLink();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Buchung erfolgreich</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg-a: #0f4c5c;
|
||||||
|
--bg-b: #16697a;
|
||||||
|
--ok: #22c55e;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #475569;
|
||||||
|
--card: #ffffff;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Manrope", "Segoe UI", sans-serif;
|
||||||
|
background: radial-gradient(circle at 20% 20%, rgba(255,255,255,0.14), transparent 45%), linear-gradient(135deg, var(--bg-a), var(--bg-b));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.success-window {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
box-shadow: 0 28px 70px rgba(2, 6, 23, 0.32);
|
||||||
|
padding: clamp(1.5rem, 4vw, 3rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
margin: 0 auto 1.25rem auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.9rem 0;
|
||||||
|
font-size: clamp(1.8rem, 5vw, 3rem);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: 1.8rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
padding: .85rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-close {
|
||||||
|
background: #0f4c5c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-link {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="success-window" role="main" aria-live="polite">
|
||||||
|
<div class="badge">✓</div>
|
||||||
|
<h1>Buchung erfolgreich</h1>
|
||||||
|
<p>Sie können das Fenster jetzt schließen.</p>
|
||||||
|
|
||||||
|
{% if client_name or slot_start %}
|
||||||
|
<div class="meta">
|
||||||
|
{% if client_name %}
|
||||||
|
<div>Name: {{ client_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if slot_start %}
|
||||||
|
<div>Termin: {{ slot_start }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-close" type="button" onclick="window.close()">Fenster schließen</button>
|
||||||
|
<a class="btn btn-link" href="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}">Zurück zur Buchungsseite</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -14,41 +14,99 @@
|
|||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="title" class="form-label fw-semibold">Titel des Terminplans</label>
|
||||||
|
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
|
||||||
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
||||||
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
|
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
||||||
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
|
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
|
||||||
<label for="time_frame" class="form-label fw-semibold">Zeitfenster</label>
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
||||||
<textarea id="time_frame" name="time_frame" class="form-control" rows="5" placeholder="Beispiele:\nMontag 08:00-12:00\nDienstag 08:00-12:00" required></textarea>
|
<div>
|
||||||
<div class="form-text">Jede Zeile kann ein eigenes Zeitfenster enthalten.</div>
|
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
|
||||||
|
<div class="form-text mb-0">Die Start-, End- und Pausenzeiten werden auf alle erzeugten Tage angewandt.</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<label for="default_day_start" class="form-label fw-semibold">Standard-Start</label>
|
||||||
|
<input type="time" id="default_day_start" class="form-control" value="08:00">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
|
||||||
|
<input type="time" id="default_day_end" class="form-control" value="16:00">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<label for="default_pause_start" class="form-label fw-semibold text-danger">Pause von</label>
|
||||||
|
<input type="time" id="default_pause_start" class="form-control border-danger-subtle" value="12:00">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<label for="default_pause_end" class="form-label fw-semibold text-danger">Pause bis</label>
|
||||||
|
<input type="time" id="default_pause_end" class="form-control border-danger-subtle" value="12:45">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="time_frame_days" class="vstack gap-2"></div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster (Übertragung ans Backend)</label>
|
||||||
|
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" readonly></textarea>
|
||||||
|
<div class="form-text">Pausen teilen den Tag automatisch in zwei buchbare Blöcke, sodass die Pause sicher blockiert ist.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
|
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||||
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
|
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold text-primary">Nutzer pro Slot</label>
|
||||||
|
<input type="number" id="clients_per_slot" name="clients_per_slot" value="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
||||||
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
|
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
||||||
|
<input type="hidden" id="slots_amounts" name="slots_amounts" value="0">
|
||||||
|
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="custom-fields-container" class="mt-4">
|
||||||
|
<h3 class="mb-3">Benutzerdefinierte Felder</h3>
|
||||||
|
|
||||||
|
<div class="mb-3 custom-field-row">
|
||||||
|
<input type="text"
|
||||||
|
name="custom_fields"
|
||||||
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
|
placeholder="Zusatzfeld hinzufügen (z.B. Firma, Kundennummer...)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{% if mail_service_enabled %}
|
||||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
<div>
|
||||||
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||||
</div>
|
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="note" class="form-label fw-semibold">Notiz</label>
|
<label for="note" class="form-label fw-semibold">Notiz</label>
|
||||||
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
||||||
|
<div class="form-text">Erzeugt zusätzlich eine .ics-Datei, die in gängige Kalender importiert werden kann.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||||
@@ -62,11 +120,341 @@
|
|||||||
{% if generated_link %}
|
{% if generated_link %}
|
||||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||||
<div class="mb-2">Teilen Sie diesen Link mit den Teilnehmenden:</div>
|
<div class="mb-2">
|
||||||
|
{% if mail_service_enabled %}
|
||||||
|
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||||
|
{% else %}
|
||||||
|
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
|
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
|
||||||
|
{% if calendar_link %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="fw-semibold mb-1">Kalendereintrag</div>
|
||||||
|
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
<script>
|
||||||
|
(function () {
|
||||||
|
const startDateInput = document.getElementById('start_date');
|
||||||
|
const endDateInput = document.getElementById('end_date');
|
||||||
|
const buildButton = document.getElementById('build_time_frame');
|
||||||
|
const daysContainer = document.getElementById('time_frame_days');
|
||||||
|
const timeFrameTextarea = document.getElementById('time_frame');
|
||||||
|
|
||||||
|
const defaultStartInput = document.getElementById('default_day_start');
|
||||||
|
const defaultEndInput = document.getElementById('default_day_end');
|
||||||
|
const defaultPauseStartInput = document.getElementById('default_pause_start');
|
||||||
|
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
||||||
|
|
||||||
|
const slotLengthInput = document.getElementById('slot_length');
|
||||||
|
const clientsperslot = document.getElementById('clients_per_slot')
|
||||||
|
const slotsAmountsInput = document.getElementById('slots_amounts');
|
||||||
|
const slotsAmountsDisplay = document.getElementById('slots_amounts_display');
|
||||||
|
|
||||||
|
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseDate(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const parts = value.split('-').map(Number);
|
||||||
|
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
|
||||||
|
return new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForRow(date) {
|
||||||
|
return weekdayFormatter.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForValue(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
const copy = new Date(date);
|
||||||
|
copy.setDate(copy.getDate() + days);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToMinutes(timeStr) {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const parts = timeStr.split(':').map(Number);
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlotsDisplay(totalSlots) {
|
||||||
|
if (slotsAmountsInput) slotsAmountsInput.value = totalSlots;
|
||||||
|
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper utility in case your timeToMinutes handler doesn't catch empty strings safely
|
||||||
|
function safeTimeToMinutes(timeString) {
|
||||||
|
if (!timeString || typeof timeString !== 'string' || !timeString.includes(':')) {
|
||||||
|
return 0; // Return 0 instead of NaN for empty or missing values
|
||||||
|
}
|
||||||
|
const parts = timeString.split(':');
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return 0;
|
||||||
|
return (hours * 60) + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
||||||
|
function calculateSlots() {
|
||||||
|
if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
|
||||||
|
|
||||||
|
const slotLength = parseInt(slotLengthInput.value, 10);
|
||||||
|
if (isNaN(slotLength) || slotLength <= 0) {
|
||||||
|
updateSlotsDisplay(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX 1: Make sure we parse the input VALUE, defaulting to 1 if empty or invalid
|
||||||
|
const multiplier = parseInt(clientsperslot.value, 10) || 1;
|
||||||
|
|
||||||
|
let totalSlots = 0;
|
||||||
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const startTime = row.querySelector('[data-time-start]')?.value;
|
||||||
|
const endTime = row.querySelector('[data-time-end]')?.value;
|
||||||
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value;
|
||||||
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value;
|
||||||
|
|
||||||
|
if (!startTime || !endTime) return;
|
||||||
|
|
||||||
|
// Using safety parsing to prevent missing values from generating NaN
|
||||||
|
const startMins = safeTimeToMinutes(startTime);
|
||||||
|
const endMins = safeTimeToMinutes(endTime);
|
||||||
|
const pauseStartMins = safeTimeToMinutes(pauseStart);
|
||||||
|
const pauseEndMins = safeTimeToMinutes(pauseEnd);
|
||||||
|
|
||||||
|
if (isNaN(startMins) || isNaN(endMins) || endMins <= startMins) return;
|
||||||
|
|
||||||
|
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
||||||
|
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
||||||
|
// Segment 1 (Vor der Pause)
|
||||||
|
const segment1 = pauseStartMins - startMins;
|
||||||
|
totalSlots += Math.floor(segment1 / slotLength);
|
||||||
|
|
||||||
|
// Segment 2 (Nach der Pause)
|
||||||
|
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
||||||
|
const segment2 = endMins - effectivePauseEnd;
|
||||||
|
if (segment2 > 0) {
|
||||||
|
totalSlots += Math.floor(segment2 / slotLength);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ohne Pause oder ungültige Pause -> Komplett durchrechnen
|
||||||
|
totalSlots += Math.floor((endMins - startMins) / slotLength);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIX 2: Multiply by our safely parsed input value scalar
|
||||||
|
const totalSlotscomplete = totalSlots * multiplier;
|
||||||
|
|
||||||
|
// Safety check: if anything went wrong anyway, fallback to 0 instead of displaying NaN
|
||||||
|
updateSlotsDisplay(isNaN(totalSlotscomplete) ? 0 : totalSlotscomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
||||||
|
function syncTextarea() {
|
||||||
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
const dayValue = row.getAttribute('data-day-value') || '';
|
||||||
|
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||||
|
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||||
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
||||||
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
||||||
|
|
||||||
|
if (!dayValue || !startTime || !endTime) return;
|
||||||
|
|
||||||
|
// Hat der Nutzer eine Pause eingetragen, die innerhalb des Tagesfensters liegt?
|
||||||
|
if (pauseStart && pauseEnd && pauseStart < pauseEnd && pauseStart > startTime && pauseStart < endTime) {
|
||||||
|
// Zeile am Pausenbeginn aufsplitten -> 2 Blöcke = automatische Pause
|
||||||
|
lines.push(`${dayValue} ${startTime}-${pauseStart}`);
|
||||||
|
lines.push(`${dayValue} ${pauseEnd}-${endTime}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`${dayValue} ${startTime}-${endTime}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
timeFrameTextarea.value = lines.join('\n');
|
||||||
|
calculateSlots(); // Nach jedem Update neu berechnen
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
const startDate = parseDate(startDateInput.value);
|
||||||
|
const endDate = parseDate(endDateInput.value);
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
daysContainer.innerHTML = '<div class="text-muted small">Bitte Start- und Enddatum auswählen, damit die Tageszeilen erzeugt werden.</div>';
|
||||||
|
timeFrameTextarea.value = '';
|
||||||
|
endDateInput.setCustomValidity('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate < startDate) {
|
||||||
|
daysContainer.innerHTML = '<div class="text-danger small">Das Enddatum muss nach dem Startdatum liegen.</div>';
|
||||||
|
timeFrameTextarea.value = '';
|
||||||
|
endDateInput.setCustomValidity('Das Enddatum muss nach dem Startdatum liegen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
endDateInput.setCustomValidity('');
|
||||||
|
|
||||||
|
// Bisherige Werte sichern
|
||||||
|
const existingValues = new Map();
|
||||||
|
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
|
||||||
|
const dayValue = row.getAttribute('data-day-value');
|
||||||
|
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||||
|
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||||
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
||||||
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
||||||
|
|
||||||
|
if (dayValue) {
|
||||||
|
existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultStart = defaultStartInput.value || '08:00';
|
||||||
|
const defaultEnd = defaultEndInput.value || '16:00';
|
||||||
|
const defaultPauseStart = defaultPauseStartInput.value || '';
|
||||||
|
const defaultPauseEnd = defaultPauseEndInput.value || '';
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
|
||||||
|
const dayValue = formatDateForValue(current);
|
||||||
|
const preserved = existingValues.get(dayValue) || {};
|
||||||
|
|
||||||
|
const rowStart = preserved.startTime !== undefined ? preserved.startTime : defaultStart;
|
||||||
|
const rowEnd = preserved.endTime !== undefined ? preserved.endTime : defaultEnd;
|
||||||
|
const rowPauseStart = preserved.pauseStart !== undefined ? preserved.pauseStart : defaultPauseStart;
|
||||||
|
const rowPauseEnd = preserved.pauseEnd !== undefined ? preserved.pauseEnd : defaultPauseEnd;
|
||||||
|
|
||||||
|
rows.push(`
|
||||||
|
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
|
||||||
|
<div class="text-muted small">${dayValue}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label mb-1 text-muted small">Von</label>
|
||||||
|
<input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label mb-1 text-muted small">Bis</label>
|
||||||
|
<input type="time" class="form-control form-control-sm" value="${rowEnd}" data-time-end>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label mb-1 text-danger small">Pause ab</label>
|
||||||
|
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseStart}" data-time-pause-start>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label mb-1 text-danger small">Pause bis</label>
|
||||||
|
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseEnd}" data-time-pause-end>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
daysContainer.innerHTML = rows.join('');
|
||||||
|
daysContainer.querySelectorAll('input[type="time"]').forEach(function (input) {
|
||||||
|
input.addEventListener('input', syncTextarea);
|
||||||
|
input.addEventListener('change', syncTextarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncTextarea();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const container = document.getElementById('custom-fields-container');
|
||||||
|
|
||||||
|
// Monitor inputs inside the container using event delegation
|
||||||
|
container.addEventListener('input', function (event) {
|
||||||
|
// Only trigger if the user is typing inside our dynamic inputs
|
||||||
|
if (event.target.classList.contains('custom-dynamic-input')) {
|
||||||
|
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
||||||
|
const lastInput = allInputs[allInputs.length - 1];
|
||||||
|
|
||||||
|
// If the user typed something into the absolute last input field, spawn a new one
|
||||||
|
if (lastInput.value.trim() !== '') {
|
||||||
|
createNewFieldRow(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to generate and append a clean new input row
|
||||||
|
function createNewFieldRow(targetContainer) {
|
||||||
|
const rowDiv = document.createElement('div');
|
||||||
|
rowDiv.className = 'mb-3 custom-field-row';
|
||||||
|
|
||||||
|
rowDiv.innerHTML = `
|
||||||
|
<input type="text"
|
||||||
|
name="custom_fields"
|
||||||
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
|
placeholder="Nächstes Zusatzfeld...">
|
||||||
|
`;
|
||||||
|
|
||||||
|
targetContainer.appendChild(rowDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Listeners Registration
|
||||||
|
buildButton.addEventListener('click', renderRows);
|
||||||
|
startDateInput.addEventListener('change', renderRows);
|
||||||
|
endDateInput.addEventListener('change', renderRows);
|
||||||
|
|
||||||
|
defaultStartInput.addEventListener('change', renderRows);
|
||||||
|
defaultEndInput.addEventListener('change', renderRows);
|
||||||
|
defaultPauseStartInput.addEventListener('change', renderRows);
|
||||||
|
defaultPauseEndInput.addEventListener('change', renderRows);
|
||||||
|
|
||||||
|
// Slot Berechnung auf Ändeurng der Länge antriggern
|
||||||
|
if(slotLengthInput) {
|
||||||
|
slotLengthInput.addEventListener('input', calculateSlots);
|
||||||
|
slotLengthInput.addEventListener('change', calculateSlots);
|
||||||
|
}
|
||||||
|
if (clientsperslot) {
|
||||||
|
clientsperslot.addEventListener('input', calculateSlots);
|
||||||
|
clientsperslot.addEventListener('change', calculateSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDateInput.value && endDateInput.value) {
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const configForm = document.querySelector('form');
|
||||||
|
if (configForm) {
|
||||||
|
configForm.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
if (event.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (event.target.tagName === 'BUTTON' && event.target.type === 'submit') return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+187
-809
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
|||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12 col-lg-11 col-xl-10">
|
<div class="col-12 col-lg-11 col-xl-10">
|
||||||
|
|
||||||
|
<!-- Hero Sektion -->
|
||||||
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
||||||
<div>
|
<div>
|
||||||
@@ -14,12 +16,45 @@
|
|||||||
<p class="lead mb-0" style="max-width: 60ch; opacity: .95;">Erstellen Sie neue Terminreihen, teilen Sie Buchungslinks und öffnen Sie den Kalender für bestehende Reservierungen.</p>
|
<p class="lead mb-0" style="max-width: 60ch; opacity: .95;">Erstellen Sie neue Terminreihen, teilen Sie Buchungslinks und öffnen Sie den Kalender für bestehende Reservierungen.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<a class="btn btn-light btn-lg fw-semibold" href="{{ url_for('terminplaner.configure') }}">Neue Planung</a>
|
<a class="btn btn-light btn-lg fw-semibold" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Neue Planung</a>
|
||||||
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan') }}">Kalender öffnen</a>
|
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan', tenant=tenant_id) }}">Kalender öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- NEU EINGEBAUT: Erfolgsmeldung für gerade erstellte Buchungslinks -->
|
||||||
|
{% if generated_link %}
|
||||||
|
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||||
|
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if mail_service_enabled %}
|
||||||
|
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||||
|
{% else %}
|
||||||
|
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="{{ generated_link }}" class="d-inline-block text-break mb-3">{{ generated_link }}</a>
|
||||||
|
|
||||||
|
<!-- PDF Export per ReportLab -->
|
||||||
|
<div class="pt-3 border-top border-success-subtle">
|
||||||
|
<div class="fw-semibold mb-1">Einladungsbrief (inkl. QR-Code)</div>
|
||||||
|
<p class="small text-muted mb-2">Laden Sie einen automatisch generierten Brief herunter, der den Buchungslink und einen passenden QR-Code enthält.</p>
|
||||||
|
<a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm">
|
||||||
|
📄 Brief als PDF herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if calendar_link %}
|
||||||
|
<div class="mt-3 pt-3 border-top border-success-subtle">
|
||||||
|
<div class="fw-semibold mb-1">Kalendereintrag</div>
|
||||||
|
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- ENDE NEUER BLOCK -->
|
||||||
|
|
||||||
|
<!-- Info-Karten -->
|
||||||
<div class="row g-4 mt-1">
|
<div class="row g-4 mt-1">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||||
@@ -27,7 +62,7 @@
|
|||||||
<div class="display-6 mb-3">🗓️</div>
|
<div class="display-6 mb-3">🗓️</div>
|
||||||
<h2 class="h4 fw-bold">Kalender</h2>
|
<h2 class="h4 fw-bold">Kalender</h2>
|
||||||
<p class="mb-4 text-muted">Sehen Sie vorhandene Termine, ihre Auslastung und die aktuellen Reservierungen im Kalender.</p>
|
<p class="mb-4 text-muted">Sehen Sie vorhandene Termine, ihre Auslastung und die aktuellen Reservierungen im Kalender.</p>
|
||||||
<a class="btn btn-primary w-100" href="{{ url_for('terminplan') }}">Zum Kalender</a>
|
<a class="btn btn-primary w-100" href="{{ url_for('terminplan', tenant=tenant_id) }}">Zum Kalender</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +72,7 @@
|
|||||||
<div class="display-6 mb-3">✍️</div>
|
<div class="display-6 mb-3">✍️</div>
|
||||||
<h2 class="h4 fw-bold">Neue Planung</h2>
|
<h2 class="h4 fw-bold">Neue Planung</h2>
|
||||||
<p class="mb-4 text-muted">Erstellen Sie einen neuen Terminplan und verschicken Sie den Buchungslink an Ihre Zielgruppe.</p>
|
<p class="mb-4 text-muted">Erstellen Sie einen neuen Terminplan und verschicken Sie den Buchungslink an Ihre Zielgruppe.</p>
|
||||||
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure') }}">Konfiguration öffnen</a>
|
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Konfiguration öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,8 +93,60 @@
|
|||||||
<h2 class="h5 fw-bold mb-2">Angemeldet als {{ current_user }}</h2>
|
<h2 class="h5 fw-bold mb-2">Angemeldet als {{ current_user }}</h2>
|
||||||
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- "Tabelle" (Liste) der kommenden Termine -->
|
||||||
|
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||||
|
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
|
||||||
|
<span class="text-muted small">Alle offenen Terminpläne dieses Nutzers</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if upcoming_events %}
|
||||||
|
<div class="vstack gap-3">
|
||||||
|
{% for event in upcoming_events %}
|
||||||
|
<div class="border rounded-3 p-3">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h6 fw-bold mb-1">{{ event.title or "Terminplan" }}</h3>
|
||||||
|
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
||||||
|
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
||||||
|
{% if event.time_span %}
|
||||||
|
<div class="small mt-1">Zeitfenster: {{ event.time_span|join(' | ') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if event.note %}
|
||||||
|
<div class="small mt-1 text-muted">Notiz: {{ event.note }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-lg-end">
|
||||||
|
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
|
||||||
|
|
||||||
|
<!-- Hinzugefügt: Direkter PDF-Export auch bei bestehenden Plänen -->
|
||||||
|
<a class="btn btn-sm btn-outline-success" href="{{ url_for('terminplaner.export_pdf_brief', plan_id=event.appointment_id) }}" title="Brief herunterladen">
|
||||||
|
📄 PDF
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link</a>
|
||||||
|
|
||||||
|
{% if event.calendar_link %}
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Keine kommenden Termine gefunden.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Termin nicht gefunden - Inventarsystem{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8 col-xl-7">
|
||||||
|
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||||
|
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||||
|
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Terminplaner</p>
|
||||||
|
<h1 class="h3 mb-0 fw-bold">Termin nicht gefunden</h1>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4 p-md-5 bg-white text-center">
|
||||||
|
<div class="display-3 fw-bold text-danger mb-3">404</div>
|
||||||
|
<p class="lead mb-2">Der angeforderte Termin existiert nicht mehr oder der Link ist ungültig.</p>
|
||||||
|
<p class="text-muted mb-4">Bitte prüfen Sie den Link erneut oder öffnen Sie den Terminplaner von der Startseite aus.</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
|
||||||
|
<a href="{{ url_for('home') }}" class="btn btn-primary btn-lg">Zur Startseite</a>
|
||||||
|
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+345
-292
@@ -155,6 +155,27 @@
|
|||||||
background-color: #1e7e34;
|
background-color: #1e7e34;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quagga Scanner Video Styles */
|
||||||
|
#code4-scanner video, #code4-scanner canvas,
|
||||||
|
#isbn-scanner video, #isbn-scanner canvas {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#code4-scanner canvas, #isbn-scanner canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code4-scanner, #isbn-scanner {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ISBN input group styling */
|
/* ISBN input group styling */
|
||||||
.isbn-input-group {
|
.isbn-input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -767,7 +788,19 @@
|
|||||||
{% if show_library_features %}
|
{% if show_library_features %}
|
||||||
<!-- Library Mode: Single Customizable Filter -->
|
<!-- Library Mode: Single Customizable Filter -->
|
||||||
<div class="filter-inputs">
|
<div class="filter-inputs">
|
||||||
<h3>Kategorie/Typ (customisierbar):</h3>
|
<h3>Medientyp</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<select name="item_type_input" id="item_type_input">
|
||||||
|
<option value="" disabled selected>Medientyp auswählen...</option>
|
||||||
|
<option value="Buch">Buch</option>
|
||||||
|
<option value="Schulbuch">Schulbuch</option>
|
||||||
|
<option value="CD">CD</option>
|
||||||
|
<option value="DVD">DVD</option>
|
||||||
|
<option value="Sonstiges">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
<small style="display:block; color:#666;">Wählen Sie einen Medientyp aus zur Klassifizierung.</small>
|
||||||
|
</div>
|
||||||
|
<h3>Kategorie/Typ:</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
|
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
|
||||||
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
|
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
|
||||||
@@ -831,7 +864,7 @@
|
|||||||
<!-- Options will be loaded by JavaScript -->
|
<!-- Options will be loaded by JavaScript -->
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="formupload_admin-group">
|
||||||
<label for="filter2-4">Wert 4:</label>
|
<label for="filter2-4">Wert 4:</label>
|
||||||
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
|
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
|
||||||
<option value="">-- Optional --</option>
|
<option value="">-- Optional --</option>
|
||||||
@@ -840,7 +873,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Thema:</h3>
|
<h3>Schlagwort:</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter3-1">Wert 1:</label>
|
<label for="filter3-1">Wert 1:</label>
|
||||||
@@ -869,25 +902,41 @@
|
|||||||
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
||||||
<input id="anschaffungskosten" name="anschaffungskosten">
|
<input id="anschaffungskosten" name="anschaffungskosten">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="code_4">Code</label>
|
<label for="scan_mode">Erfassungs-Modus:</label>
|
||||||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
<select id="scan_mode" name="scan_mode" class="form-control" onchange="toggleScanMode()">
|
||||||
<input id="code_4" name="code_4">
|
<option value="single">Einzel-Code (Scanner stoppt nach 1 Scan)</option>
|
||||||
<button type="button" id="scan-code4-btn" class="fetch-isbn-button">Barcode scannen</button>
|
<option value="continuous">Fortlaufend scannen (Mehrere Codes nacheinander)</option>
|
||||||
|
<option value="range">Code-Bereich generieren (z.B. ABC-001 bis ABC-010)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="range_generator_group" class="form-group" style="display:none; background: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #ddd; margin-bottom: 15px;">
|
||||||
|
<label style="font-weight: bold;">Code-Bereich automatisch generieren:</label>
|
||||||
|
<div style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
|
||||||
|
<input type="text" id="range_prefix" placeholder="Präfix (z.B. IT-)" class="form-control" style="flex: 2;">
|
||||||
|
<input type="number" id="range_start" placeholder="Start (z.B. 1)" class="form-control" style="flex: 1;">
|
||||||
|
<span style="font-weight: bold;">bis</span>
|
||||||
|
<input type="text" id="range_end" placeholder="Ende (z.B. 020)" class="form-control" style="flex: 1;">
|
||||||
</div>
|
</div>
|
||||||
<small style="display:block; color:#666;">Einzelcode manuell setzen oder per Scanner erfassen.</small>
|
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
|
||||||
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
|
||||||
<small id="code4-scan-status" style="display:block; color:#666; margin-top:6px;"></small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="item_count">Anzahl dieser Sorte</label>
|
<div class="form-group" id="primary_code_group">
|
||||||
<input type="number" id="item_count" name="item_count" min="1" max="100" value="1">
|
<label for="code_4">Basis-Code</label>
|
||||||
<small style="display:block; color:#666;">Es werden eigene Items erstellt, aber in der Übersicht als ein Artikel gebündelt angezeigt.</small>
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<input type="text" id="code_4" name="code_4" class="form-control" placeholder="Haupt-Barcode" required>
|
||||||
|
<button type="button" id="scan-code4-btn" class="btn btn-primary">Barcode scannen</button>
|
||||||
|
</div>
|
||||||
|
<div id="code4-scanner" style="display:none; margin-top: 10px;"></div>
|
||||||
|
<small id="code4-scan-status" class="form-text text-muted"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label>
|
<div class="form-group" id="individual_codes_group">
|
||||||
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea>
|
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
|
||||||
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes können hier bei Bedarf manuell ergänzt werden.</small>
|
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B. ABC-001 ABC-002" class="form-control"></textarea>
|
||||||
|
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
|
||||||
</div>
|
</div>
|
||||||
<!-- Image upload (hidden for library mode) -->
|
<!-- Image upload (hidden for library mode) -->
|
||||||
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
||||||
@@ -905,7 +954,7 @@
|
|||||||
<div class="isbn-input-group">
|
<div class="isbn-input-group">
|
||||||
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
|
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
|
||||||
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
|
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
|
||||||
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
|
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen abrufen</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
||||||
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
||||||
@@ -970,8 +1019,7 @@
|
|||||||
document.getElementById('csv-help-modal').style.display = 'none';
|
document.getElementById('csv-help-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
||||||
|
|
||||||
@@ -984,12 +1032,12 @@
|
|||||||
|
|
||||||
// Global variable to store fetched book data
|
// Global variable to store fetched book data
|
||||||
let currentBookData = null;
|
let currentBookData = null;
|
||||||
let code4ScannerInstance = null;
|
|
||||||
let code4ScannerRunning = false;
|
// Quagga2 engine state management properties
|
||||||
let code4LastScanned = '';
|
let scannerRunning = false;
|
||||||
let code4LastScannedAt = 0;
|
let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
|
||||||
let isbnScannerInstance = null;
|
var code4LastScanned = '';
|
||||||
let isbnScannerRunning = false;
|
var code4LastScannedAt = 0;
|
||||||
|
|
||||||
function setCode4ScanStatus(message, isError = false) {
|
function setCode4ScanStatus(message, isError = false) {
|
||||||
const statusEl = document.getElementById('code4-scan-status');
|
const statusEl = document.getElementById('code4-scan-status');
|
||||||
@@ -1042,61 +1090,230 @@
|
|||||||
statusEl.style.color = '#b00020';
|
statusEl.style.color = '#b00020';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Centered Quagga2 Engine Initialization Pattern
|
||||||
|
function runEngineInitialization(targetSelector, activeCallback, completionMsg, errorStatusSetter) {
|
||||||
|
if (scannerRunning) {
|
||||||
|
Quagga.stop();
|
||||||
|
scannerRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeScannerCallback = activeCallback;
|
||||||
|
|
||||||
|
Quagga.init({
|
||||||
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
|
target: document.querySelector(targetSelector),
|
||||||
|
constraints: {
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
facingMode: "environment" // Force rear camera lenses
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decoder: {
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
errorStatusSetter("Kamera konnte nicht gestartet werden.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
scannerRunning = true;
|
||||||
|
errorStatusSetter(completionMsg, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function killScannerHardware() {
|
||||||
|
if (!scannerRunning) return;
|
||||||
|
Quagga.stop();
|
||||||
|
scannerRunning = false;
|
||||||
|
activeScannerCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified tracking capture listener
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
if (!data || !data.codeResult || !data.codeResult.code) return;
|
||||||
|
|
||||||
|
const rawBarcode = String(data.codeResult.code || '').trim();
|
||||||
|
const currentTargetCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
if (typeof currentTargetCallback === "function") {
|
||||||
|
currentTargetCallback(rawBarcode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Globale Variablen für den Scanner-Status
|
||||||
|
var code4LastScanned = '';
|
||||||
|
var code4LastScannedAt = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schaltet das UI basierend auf dem ausgewählten Modus um
|
||||||
|
*/
|
||||||
|
function toggleScanMode() {
|
||||||
|
const mode = document.getElementById('scan_mode').value;
|
||||||
|
const rangeGroup = document.getElementById('range_generator_group');
|
||||||
|
const scanButton = document.getElementById('scan-code4-btn');
|
||||||
|
const scannerBox = document.getElementById('code4-scanner');
|
||||||
|
|
||||||
|
if (scannerBox && scannerBox.style.display !== 'none') {
|
||||||
|
if (typeof killScannerHardware === 'function') killScannerHardware();
|
||||||
|
scannerBox.style.display = 'none';
|
||||||
|
if (scanButton) scanButton.textContent = 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'range') {
|
||||||
|
if(rangeGroup) rangeGroup.style.display = 'block';
|
||||||
|
if(scanButton) scanButton.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if(rangeGroup) rangeGroup.style.display = 'none'; // Hier wurde der Fehler korrigiert
|
||||||
|
if(scanButton) scanButton.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert eine Liste von Codes basierend auf den Eingaben
|
||||||
|
*/
|
||||||
|
function generateCodeRange() {
|
||||||
|
const prefix = document.getElementById('range_prefix').value.trim();
|
||||||
|
const startStr = document.getElementById('range_start').value.trim();
|
||||||
|
const endStr = document.getElementById('range_end').value.trim();
|
||||||
|
|
||||||
|
const start = parseInt(startStr, 10);
|
||||||
|
const end = parseInt(endStr, 10);
|
||||||
|
|
||||||
|
const paddingLength = endStr.length;
|
||||||
|
|
||||||
|
if (isNaN(start) || isNaN(end) || start > end) {
|
||||||
|
alert("Bitte gültige Zahlen für Start und Ende eingeben. Der Startwert darf nicht größer als der Endwert sein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let generatedCodes = [];
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
let numStr = i.toString().padStart(paddingLength, '0');
|
||||||
|
generatedCodes.push(`${prefix}${numStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeField = document.getElementById('code_4');
|
||||||
|
const individualCodesArea = document.getElementById('individual_codes');
|
||||||
|
|
||||||
|
if (generatedCodes.length > 0) {
|
||||||
|
if (!codeField.value || confirm("Soll der aktuelle Basis-Code überschrieben werden?")) {
|
||||||
|
codeField.value = generatedCodes[0];
|
||||||
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
|
generatedCodes.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generatedCodes.length > 0) {
|
||||||
|
let existing = individualCodesArea.value.trim();
|
||||||
|
individualCodesArea.value = existing ? existing + '\n' + generatedCodes.join('\n') : generatedCodes.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`Bereich erfolgreich generiert! (${end - start + 1} Codes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanner-Funktion (Single & Continuous Modus)
|
||||||
|
*/
|
||||||
function startCode4Scanner() {
|
function startCode4Scanner() {
|
||||||
const scannerBox = document.getElementById('code4-scanner');
|
const scannerBox = document.getElementById('code4-scanner');
|
||||||
const scanButton = document.getElementById('scan-code4-btn');
|
const scanButton = document.getElementById('scan-code4-btn');
|
||||||
const codeField = document.getElementById('code_4');
|
const codeField = document.getElementById('code_4');
|
||||||
|
const individualCodesArea = document.getElementById('individual_codes');
|
||||||
|
const scanMode = document.getElementById('scan_mode').value;
|
||||||
|
|
||||||
if (!scannerBox || !scanButton || !codeField) return;
|
if (!scannerBox || !scanButton || !codeField) return;
|
||||||
|
|
||||||
// Stop ISBN scanner if currently running to avoid camera conflicts.
|
// Beende evtl. laufenden ISBN Scanner
|
||||||
if (isbnScannerRunning && isbnScannerInstance) {
|
const isbnScannerBox = document.getElementById('isbn-scanner');
|
||||||
isbnScannerInstance.clear().catch(() => {});
|
if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
|
||||||
const isbnScannerBox = document.getElementById('isbn-scanner');
|
killScannerHardware();
|
||||||
|
isbnScannerBox.style.display = 'none';
|
||||||
const isbnScanButton = document.getElementById('scan-isbn-btn');
|
const isbnScanButton = document.getElementById('scan-isbn-btn');
|
||||||
if (isbnScannerBox) isbnScannerBox.style.display = 'none';
|
if (isbnScanButton) isbnScanButton.textContent = 'Barcode scannen';
|
||||||
if (isbnScanButton) isbnScanButton.textContent = 'ISBN scannen';
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||||
isbnScannerRunning = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code4ScannerRunning && code4ScannerInstance) {
|
// Scanner ausschalten, wenn er bereits läuft
|
||||||
code4ScannerInstance.clear().catch(() => {});
|
if (scannerBox.style.display !== 'none') {
|
||||||
|
killScannerHardware();
|
||||||
scannerBox.style.display = 'none';
|
scannerBox.style.display = 'none';
|
||||||
scanButton.textContent = 'Barcode scannen';
|
scanButton.textContent = 'Barcode scannen';
|
||||||
code4ScannerRunning = false;
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||||
setCode4ScanStatus('Scanner gestoppt.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scanner starten
|
||||||
scannerBox.style.display = 'block';
|
scannerBox.style.display = 'block';
|
||||||
scanButton.textContent = 'Scanner stoppen';
|
scanButton.textContent = scanMode === 'continuous' ? 'Fortlaufenden Scan stoppen' : 'Scanner stoppen';
|
||||||
code4ScannerInstance = new Html5QrcodeScanner('code4-scanner', {
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner initialisiert. Richte die Kamera auf den Barcode...');
|
||||||
fps: 10,
|
|
||||||
qrbox: 250,
|
|
||||||
rememberLastUsedCamera: true
|
|
||||||
});
|
|
||||||
code4ScannerRunning = true;
|
|
||||||
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
|
|
||||||
|
|
||||||
code4ScannerInstance.render((decodedText) => {
|
if (typeof runEngineInitialization === 'function') {
|
||||||
const scannedCode = String(decodedText || '').trim();
|
runEngineInitialization(
|
||||||
if (!scannedCode) return;
|
'#code4-scanner',
|
||||||
|
function(decodedText) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (decodedText === code4LastScanned && (now - code4LastScannedAt) < 1500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
code4LastScanned = decodedText;
|
||||||
|
code4LastScannedAt = now;
|
||||||
|
|
||||||
|
if (scanMode === 'single') {
|
||||||
|
killScannerHardware();
|
||||||
|
scannerBox.style.display = 'none';
|
||||||
|
scanButton.textContent = 'Barcode scannen';
|
||||||
|
|
||||||
|
codeField.value = decodedText;
|
||||||
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code gesetzt: ${decodedText}`);
|
||||||
|
|
||||||
const now = Date.now();
|
} else if (scanMode === 'continuous') {
|
||||||
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) {
|
let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
|
||||||
return;
|
|
||||||
}
|
|
||||||
code4LastScanned = scannedCode;
|
|
||||||
code4LastScannedAt = now;
|
|
||||||
|
|
||||||
codeField.value = scannedCode;
|
if (!codeField.value) {
|
||||||
validateCodeField(codeField);
|
codeField.value = decodedText;
|
||||||
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
}, () => {});
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Basis-Code gesetzt: ${decodedText}`);
|
||||||
|
} else if (!currentAreaCodes.includes(decodedText) && codeField.value !== decodedText) {
|
||||||
|
individualCodesArea.value += (individualCodesArea.value ? '\n' : '') + decodedText;
|
||||||
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Zusatz-Code erfasst: ${decodedText}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const oscillator = audioCtx.createOscillator();
|
||||||
|
const gainNode = audioCtx.createGain();
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioCtx.destination);
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.value = 800;
|
||||||
|
gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioCtx.currentTime + 0.1);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Audio feedback not supported");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code ${decodedText} wurde bereits gescannt.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Scanner läuft. Richte die Kamera auf den Barcode.',
|
||||||
|
setCode4ScanStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISBN Scanner-Funktion
|
||||||
|
*/
|
||||||
function startIsbnScanner() {
|
function startIsbnScanner() {
|
||||||
if (!libraryModuleEnabled) {
|
if (!libraryModuleEnabled) {
|
||||||
setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scannerBox = document.getElementById('isbn-scanner');
|
const scannerBox = document.getElementById('isbn-scanner');
|
||||||
@@ -1104,61 +1321,75 @@
|
|||||||
const isbnField = document.getElementById('isbn');
|
const isbnField = document.getElementById('isbn');
|
||||||
if (!scannerBox || !scanButton || !isbnField) return;
|
if (!scannerBox || !scanButton || !isbnField) return;
|
||||||
|
|
||||||
// Stop Code_4 scanner if currently running to avoid camera conflicts.
|
const codeScannerBox = document.getElementById('code4-scanner');
|
||||||
if (code4ScannerRunning && code4ScannerInstance) {
|
const codeScanButton = document.getElementById('scan-code4-btn');
|
||||||
code4ScannerInstance.clear().catch(() => {});
|
if (codeScannerBox && codeScannerBox.style.display !== 'none') {
|
||||||
const codeScannerBox = document.getElementById('code4-scanner');
|
killScannerHardware();
|
||||||
const codeScanButton = document.getElementById('scan-code4-btn');
|
codeScannerBox.style.display = 'none';
|
||||||
if (codeScannerBox) codeScannerBox.style.display = 'none';
|
codeScanButton.textContent = 'Barcode scannen';
|
||||||
if (codeScanButton) codeScanButton.textContent = 'Barcode scannen';
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||||
code4ScannerRunning = false;
|
|
||||||
setCode4ScanStatus('Scanner gestoppt.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isbnScannerRunning && isbnScannerInstance) {
|
if (scannerBox.style.display !== 'none') {
|
||||||
isbnScannerInstance.clear().catch(() => {});
|
killScannerHardware();
|
||||||
scannerBox.style.display = 'none';
|
scannerBox.style.display = 'none';
|
||||||
scanButton.textContent = 'ISBN scannen';
|
scanButton.textContent = 'Barcode scannen';
|
||||||
isbnScannerRunning = false;
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||||
setIsbnScanStatus('Scanner gestoppt.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scannerBox.style.display = 'block';
|
scannerBox.style.display = 'block';
|
||||||
scanButton.textContent = 'Scanner stoppen';
|
scanButton.textContent = 'Scanner stoppen';
|
||||||
isbnScannerInstance = new Html5QrcodeScanner('isbn-scanner', {
|
|
||||||
fps: 10,
|
|
||||||
qrbox: 250,
|
|
||||||
rememberLastUsedCamera: true
|
|
||||||
});
|
|
||||||
isbnScannerRunning = true;
|
|
||||||
setIsbnScanStatus('Scanner läuft. Bitte ISBN-Code erfassen.');
|
|
||||||
|
|
||||||
isbnScannerInstance.render((decodedText) => {
|
runEngineInitialization(
|
||||||
const scannedCode = String(decodedText || '').trim();
|
'#isbn-scanner',
|
||||||
if (!scannedCode) return;
|
function(decodedText) {
|
||||||
|
killScannerHardware();
|
||||||
|
scannerBox.style.display = 'none';
|
||||||
|
scanButton.textContent = 'Barcode scannen';
|
||||||
|
|
||||||
const normalizedIsbn = normalizeIsbnClient(scannedCode);
|
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
|
||||||
isbnField.value = normalizedIsbn || scannedCode;
|
isbnField.value = normalizedIsbn || decodedText;
|
||||||
updateIsbnLiveValidation();
|
if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
|
||||||
if (normalizedIsbn) {
|
|
||||||
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
|
||||||
} else {
|
|
||||||
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
isbnScannerInstance.clear().catch(() => {});
|
if (normalizedIsbn) {
|
||||||
scannerBox.style.display = 'none';
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
||||||
scanButton.textContent = 'ISBN scannen';
|
if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
|
||||||
isbnScannerRunning = false;
|
} else {
|
||||||
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
|
||||||
if (normalizedIsbn) {
|
}
|
||||||
// Automatically load book metadata after a valid ISBN scan.
|
},
|
||||||
fetchBookInfo('upload');
|
'Scanner läuft. Bitte ISBN-Code erfassen.',
|
||||||
}
|
setIsbnScanStatus
|
||||||
}, () => {});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WICHTIG: Event Listener Binden sobald DOM geladen ist ---
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// 1. Basis-Code Scanner Button aktivieren
|
||||||
|
const scanCode4Btn = document.getElementById('scan-code4-btn');
|
||||||
|
if (scanCode4Btn) {
|
||||||
|
scanCode4Btn.addEventListener('click', startCode4Scanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ISBN Scanner Button aktivieren
|
||||||
|
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
||||||
|
if (scanIsbnBtn) {
|
||||||
|
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dropdown-Modus Wechsel überwachen
|
||||||
|
const scanModeSelect = document.getElementById('scan_mode');
|
||||||
|
if (scanModeSelect) {
|
||||||
|
scanModeSelect.addEventListener('change', toggleScanMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ISBN Live-Validierung
|
||||||
|
const isbnInput = document.getElementById('isbn');
|
||||||
|
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
|
||||||
|
isbnInput.addEventListener('input', updateIsbnLiveValidation);
|
||||||
|
}
|
||||||
|
});
|
||||||
// Load predefined filter values for dropdowns
|
// Load predefined filter values for dropdowns
|
||||||
function loadPredefinedFilterValues(filterNumber) {
|
function loadPredefinedFilterValues(filterNumber) {
|
||||||
fetch(`/get_predefined_filter_values/${filterNumber}`)
|
fetch(`/get_predefined_filter_values/${filterNumber}`)
|
||||||
@@ -1344,6 +1575,7 @@
|
|||||||
// Get form fields
|
// Get form fields
|
||||||
const nameField = document.getElementById('name');
|
const nameField = document.getElementById('name');
|
||||||
const descriptionField = document.getElementById('beschreibung');
|
const descriptionField = document.getElementById('beschreibung');
|
||||||
|
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
|
||||||
|
|
||||||
if (!nameField || !descriptionField) {
|
if (!nameField || !descriptionField) {
|
||||||
alert('Fehler: Formularfelder nicht gefunden.');
|
alert('Fehler: Formularfelder nicht gefunden.');
|
||||||
@@ -1381,6 +1613,13 @@
|
|||||||
nameField.value = bookTitle;
|
nameField.value = bookTitle;
|
||||||
descriptionField.value = description;
|
descriptionField.value = description;
|
||||||
|
|
||||||
|
// Preis in das Formularfeld eintragen
|
||||||
|
if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) {
|
||||||
|
// Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um
|
||||||
|
let formattedPrice = currentBookData.price.toString().replace('.', ',');
|
||||||
|
priceField.value = formattedPrice;
|
||||||
|
}
|
||||||
|
|
||||||
// Download and import book cover image if available
|
// Download and import book cover image if available
|
||||||
if (currentBookData.thumbnail) {
|
if (currentBookData.thumbnail) {
|
||||||
downloadBookCover(currentBookData.thumbnail);
|
downloadBookCover(currentBookData.thumbnail);
|
||||||
@@ -1558,6 +1797,7 @@
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exported function for use within parent layout fields
|
||||||
function validateCodeField(input, excludeId = null) {
|
function validateCodeField(input, excludeId = null) {
|
||||||
const code = input.value.trim();
|
const code = input.value.trim();
|
||||||
|
|
||||||
@@ -1683,16 +1923,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const scanCodeBtn = document.getElementById('scan-code4-btn');
|
|
||||||
if (scanCodeBtn) {
|
|
||||||
scanCodeBtn.addEventListener('click', startCode4Scanner);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
|
||||||
if (scanIsbnBtn) {
|
|
||||||
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isbnField = document.getElementById('isbn');
|
const isbnField = document.getElementById('isbn');
|
||||||
if (isbnField) {
|
if (isbnField) {
|
||||||
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
||||||
@@ -1732,188 +1962,11 @@
|
|||||||
if (typeof duplicateData.images === 'string') {
|
if (typeof duplicateData.images === 'string') {
|
||||||
try {
|
try {
|
||||||
duplicateData.images = JSON.parse(duplicateData.images);
|
duplicateData.images = JSON.parse(duplicateData.images);
|
||||||
console.log("Parsed images from string:", duplicateData.images);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to parse images string, using as single item:", duplicateData.images);
|
console.error("Error parsing duplicate images array:", e);
|
||||||
duplicateData.images = [duplicateData.images];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Final duplicateData.images (${Array.isArray(duplicateData.images) ? 'array' : typeof duplicateData.images}):`, duplicateData.images);
|
console.log("Parsed duplicate images successfully.");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check for sessionStorage data (takes priority)
|
|
||||||
const sessionData = sessionStorage.getItem('duplicateItemData');
|
|
||||||
if (sessionData) {
|
|
||||||
try {
|
|
||||||
duplicateData = JSON.parse(sessionData);
|
|
||||||
sessionStorage.removeItem('duplicateItemData'); // Clean up
|
|
||||||
console.log("Session storage duplicate data:", duplicateData);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing session duplicate data:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to prefill form with duplicate data
|
|
||||||
function prefillFormWithDuplicateData() {
|
|
||||||
if (!duplicateData) return;
|
|
||||||
|
|
||||||
// Fill basic fields
|
|
||||||
const nameField = document.getElementById('name');
|
|
||||||
if (nameField && duplicateData.name) nameField.value = duplicateData.name;
|
|
||||||
|
|
||||||
const descField = document.getElementById('beschreibung');
|
|
||||||
if (descField && duplicateData.description) descField.value = duplicateData.description;
|
|
||||||
|
|
||||||
const yearField = document.getElementById('anschaffungsjahr');
|
|
||||||
if (yearField && duplicateData.year) yearField.value = duplicateData.year;
|
|
||||||
|
|
||||||
const costField = document.getElementById('anschaffungskosten');
|
|
||||||
if (costField && duplicateData.cost) costField.value = duplicateData.cost;
|
|
||||||
|
|
||||||
// Fill location dropdown
|
|
||||||
const locationField = document.getElementById('ort');
|
|
||||||
if (locationField && duplicateData.location) {
|
|
||||||
// Wait for locations to load, then set value
|
|
||||||
setTimeout(() => {
|
|
||||||
locationField.value = duplicateData.location;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill filter arrays - handle up to 4 values per filter type
|
|
||||||
setTimeout(() => {
|
|
||||||
// Filter 1 (Unterrichtsfach)
|
|
||||||
if (duplicateData.filter1 && Array.isArray(duplicateData.filter1)) {
|
|
||||||
for (let j = 1; j <= Math.min(4, duplicateData.filter1.length); j++) {
|
|
||||||
const filterField = document.getElementById(`filter1-${j}`);
|
|
||||||
if (filterField && duplicateData.filter1[j-1]) {
|
|
||||||
filterField.value = duplicateData.filter1[j-1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter 2 (Jahrgangsstufe)
|
|
||||||
if (duplicateData.filter2 && Array.isArray(duplicateData.filter2)) {
|
|
||||||
for (let j = 1; j <= Math.min(4, duplicateData.filter2.length); j++) {
|
|
||||||
const filterField = document.getElementById(`filter2-${j}`);
|
|
||||||
if (filterField && duplicateData.filter2[j-1]) {
|
|
||||||
filterField.value = duplicateData.filter2[j-1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter 3 (Thema) - text inputs
|
|
||||||
if (duplicateData.filter3 && Array.isArray(duplicateData.filter3)) {
|
|
||||||
for (let j = 1; j <= Math.min(4, duplicateData.filter3.length); j++) {
|
|
||||||
const filterField = document.getElementById(`filter3-${j}`);
|
|
||||||
if (filterField && duplicateData.filter3[j-1]) {
|
|
||||||
filterField.value = duplicateData.filter3[j-1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 200); // Wait for filter options to load
|
|
||||||
|
|
||||||
// Handle images - add hidden inputs for duplicate images
|
|
||||||
if (duplicateData.images && duplicateData.images.length > 0) {
|
|
||||||
console.log("Duplicate images data:", duplicateData.images);
|
|
||||||
const form = document.querySelector('form[action*="upload_item"]');
|
|
||||||
if (form) {
|
|
||||||
// Add hidden field to indicate duplication
|
|
||||||
const isDuplicatingField = document.createElement('input');
|
|
||||||
isDuplicatingField.type = 'hidden';
|
|
||||||
isDuplicatingField.name = 'is_duplicating';
|
|
||||||
isDuplicatingField.value = 'true';
|
|
||||||
form.appendChild(isDuplicatingField);
|
|
||||||
|
|
||||||
// Add each image as a duplicate image
|
|
||||||
const images = duplicateData.images || [];
|
|
||||||
console.log(`Adding ${images.length} images as hidden fields`);
|
|
||||||
|
|
||||||
if (Array.isArray(images)) {
|
|
||||||
images.forEach((imageName, index) => {
|
|
||||||
if (imageName) {
|
|
||||||
console.log(`Adding duplicate image ${index}: ${imageName}`);
|
|
||||||
const imageField = document.createElement('input');
|
|
||||||
imageField.type = 'hidden';
|
|
||||||
imageField.name = 'duplicate_images';
|
|
||||||
imageField.value = imageName;
|
|
||||||
form.appendChild(imageField);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn("Images is not an array:", images);
|
|
||||||
// Try to handle as single item if it's a string
|
|
||||||
if (typeof images === 'string' && images) {
|
|
||||||
console.log("Adding single image:", images);
|
|
||||||
const imageField = document.createElement('input');
|
|
||||||
imageField.type = 'hidden';
|
|
||||||
imageField.name = 'duplicate_images';
|
|
||||||
imageField.value = images;
|
|
||||||
form.appendChild(imageField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show image preview
|
|
||||||
showDuplicateImagePreview(duplicateData.images);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to show preview of duplicate images
|
|
||||||
function showDuplicateImagePreview(images) {
|
|
||||||
const previewContainer = document.getElementById('image-preview-container');
|
|
||||||
if (!previewContainer) return;
|
|
||||||
|
|
||||||
console.log("showDuplicateImagePreview called with:", images);
|
|
||||||
|
|
||||||
// Make sure images is treated as an array
|
|
||||||
if (!Array.isArray(images)) {
|
|
||||||
if (typeof images === 'string') {
|
|
||||||
// Try to parse if it's a JSON string
|
|
||||||
try {
|
|
||||||
images = JSON.parse(images);
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, wrap the single string in an array
|
|
||||||
images = [images];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("Invalid images data:", images);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previewContainer.innerHTML = '';
|
|
||||||
console.log(`Processing ${images.length} images for preview`);
|
|
||||||
|
|
||||||
images.forEach((imageName, index) => {
|
|
||||||
console.log(`Processing image ${index}:`, imageName);
|
|
||||||
const preview = document.createElement('div');
|
|
||||||
preview.className = 'image-preview-item';
|
|
||||||
preview.innerHTML = `
|
|
||||||
<img src="/uploads/${imageName}" alt="Duplicate Image ${index + 1}" class="duplicate-image-preview">
|
|
||||||
<div class="image-controls">
|
|
||||||
<span>Von Original übernommen</span>
|
|
||||||
<button type="button" onclick="removeDuplicateImage('${imageName}', this)" class="remove-duplicate-image-button">Entfernen</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
previewContainer.appendChild(preview);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to remove a duplicate image
|
|
||||||
function removeDuplicateImage(imageName, button) {
|
|
||||||
// Remove the hidden input
|
|
||||||
const hiddenInputs = document.querySelectorAll('input[name="duplicate_images"]');
|
|
||||||
hiddenInputs.forEach(input => {
|
|
||||||
if (input.value === imageName) {
|
|
||||||
input.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove the preview
|
|
||||||
const previewItem = button.closest('.image-preview-item');
|
|
||||||
if (previewItem) {
|
|
||||||
previewItem.remove();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -473,6 +473,20 @@ class TenantContext:
|
|||||||
if not has_request_context():
|
if not has_request_context():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Query parameters are useful for public links that must open a specific tenant
|
||||||
|
# even when the host/subdomain cannot be mapped reliably.
|
||||||
|
tenant_from_query = (
|
||||||
|
request.args.get('tenant', '').strip()
|
||||||
|
or request.args.get('tenant_id', '').strip()
|
||||||
|
or request.args.get('tenantId', '').strip()
|
||||||
|
)
|
||||||
|
if tenant_from_query:
|
||||||
|
matched_tenant = _find_registered_tenant_id(tenant_from_query) or tenant_from_query
|
||||||
|
self.tenant_id = matched_tenant
|
||||||
|
self.config = get_tenant_config(matched_tenant)
|
||||||
|
session['tenant_id'] = matched_tenant
|
||||||
|
return self._get_db_name(matched_tenant)
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -531,6 +545,10 @@ class TenantContext:
|
|||||||
potential_subdomain = parts[0]
|
potential_subdomain = parts[0]
|
||||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||||
matched_tenant = _find_registered_tenant_id(potential_subdomain)
|
matched_tenant = _find_registered_tenant_id(potential_subdomain)
|
||||||
|
if not matched_tenant and potential_subdomain.startswith('school'):
|
||||||
|
matched_tenant = _find_registered_tenant_id('schule' + potential_subdomain[len('school'):])
|
||||||
|
elif not matched_tenant and potential_subdomain.startswith('schule'):
|
||||||
|
matched_tenant = _find_registered_tenant_id('school' + potential_subdomain[len('schule'):])
|
||||||
if matched_tenant:
|
if matched_tenant:
|
||||||
self.subdomain = potential_subdomain
|
self.subdomain = potential_subdomain
|
||||||
self.tenant_id = matched_tenant
|
self.tenant_id = matched_tenant
|
||||||
|
|||||||
@@ -53,6 +53,9 @@
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"terminplan": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_DATABASE: inventar_default
|
MONGO_INITDB_DATABASE: inventar_default
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ services:
|
|||||||
PYTHON_VERSION: "3.13"
|
PYTHON_VERSION: "3.13"
|
||||||
OPTIMIZATION_LEVEL: 2
|
OPTIMIZATION_LEVEL: 2
|
||||||
working_dir: /app/Web
|
working_dir: /app/Web
|
||||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
+195
-193
@@ -27,57 +27,14 @@ ensure_runtime_config_json() {
|
|||||||
cat > "$config_path" <<'EOF'
|
cat > "$config_path" <<'EOF'
|
||||||
{
|
{
|
||||||
"ver": "2.6.5",
|
"ver": "2.6.5",
|
||||||
"dbg": false,
|
"tenants": {}
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 8000,
|
|
||||||
"mongodb": {
|
|
||||||
"host": "mongodb",
|
|
||||||
"port": 27017,
|
|
||||||
"db": "Inventarsystem"
|
|
||||||
},
|
|
||||||
"modules": {
|
|
||||||
"library": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"terminplan": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"student_cards": {
|
|
||||||
"enabled": false,
|
|
||||||
"default_borrow_days": 14,
|
|
||||||
"max_borrow_days": 365
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
echo "Created default runtime config at $config_path"
|
echo "Created default config.json"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
show_help() {
|
get_tenant_aliases() {
|
||||||
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
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 " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
|
||||||
echo " restart-all Restart all application containers (zero-downtime reload)"
|
|
||||||
echo " list List active tenants"
|
|
||||||
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
|
|
||||||
echo " -h, --help Show this help message"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
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 module school_a inventory=off library=on"
|
|
||||||
echo " ./manage-tenant.sh restart-all"
|
|
||||||
echo " ./manage-tenant.sh -h"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
tenant_aliases() {
|
|
||||||
local tenant_id="$1"
|
local tenant_id="$1"
|
||||||
local normalized alias
|
local normalized alias
|
||||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||||
@@ -204,7 +161,8 @@ existing['modules'] = {
|
|||||||
'inventory': {'enabled': True},
|
'inventory': {'enabled': True},
|
||||||
'library': {'enabled': True},
|
'library': {'enabled': True},
|
||||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||||
'terminplan': {'enabled': True},
|
'terminplan': {'enabled': True},
|
||||||
|
'mail': {'enabled': True},
|
||||||
}
|
}
|
||||||
existing['trial'] = trial_config
|
existing['trial'] = trial_config
|
||||||
|
|
||||||
@@ -236,8 +194,8 @@ initialize_tenant_database() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker exec "$APP_CONTAINER" python3 -c '
|
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
|
||||||
import sys, re, datetime, hashlib
|
import sys, os, re, datetime, hashlib
|
||||||
sys.path.insert(0, "/app")
|
sys.path.insert(0, "/app")
|
||||||
sys.path.insert(0, "/app/Web")
|
sys.path.insert(0, "/app/Web")
|
||||||
from Web.modules.database import settings
|
from Web.modules.database import settings
|
||||||
@@ -249,7 +207,11 @@ sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
|||||||
db_name = f"inventar_{sanitized}"
|
db_name = f"inventar_{sanitized}"
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
db = client[db_name]
|
db = client[db_name]
|
||||||
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
|
|
||||||
|
pw_bytes = "admin123".encode("utf-8")
|
||||||
|
random_salt = os.urandom(16)
|
||||||
|
hashed = hashlib.scrypt(pw_bytes, salt=random_salt, n=16384, r=8, p=1)
|
||||||
|
hashed_pw_string = f"v1${random_salt.hex()}${hashed.hex()}"
|
||||||
|
|
||||||
action_permissions = {
|
action_permissions = {
|
||||||
"can_borrow": True,
|
"can_borrow": True,
|
||||||
@@ -285,7 +247,7 @@ page_permissions = {
|
|||||||
if db.users.count_documents({"Username": "admin"}) == 0:
|
if db.users.count_documents({"Username": "admin"}) == 0:
|
||||||
db.users.insert_one({
|
db.users.insert_one({
|
||||||
"Username": "admin",
|
"Username": "admin",
|
||||||
"Password": hashed_pw,
|
"Password": hashed_pw_string,
|
||||||
"Admin": True,
|
"Admin": True,
|
||||||
"active_ausleihung": None,
|
"active_ausleihung": None,
|
||||||
"name": "Admin",
|
"name": "Admin",
|
||||||
@@ -310,64 +272,66 @@ if mode == "trial":
|
|||||||
)
|
)
|
||||||
|
|
||||||
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
||||||
' "$tenant_id" "$mode"
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
||||||
local current_ports=""
|
local current_ports port_list unique_ports first_port ports_csv
|
||||||
local port_list=()
|
|
||||||
local unique_ports=()
|
if [ -z "$new_port" ]; then
|
||||||
local first_port=""
|
return 0
|
||||||
if [ -n "$new_port" ]; then
|
fi
|
||||||
if [ -f "$env_file" ]; then
|
|
||||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
current_ports=""
|
||||||
if [ -z "$current_ports" ]; then
|
if [ -f "$env_file" ]; then
|
||||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||||
fi
|
if [ -z "$current_ports" ]; then
|
||||||
|
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "$current_ports" ]; then
|
port_list=()
|
||||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
unique_ports=()
|
||||||
|
if [ -n "$current_ports" ]; then
|
||||||
|
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||||
|
fi
|
||||||
|
port_list+=("$new_port")
|
||||||
|
|
||||||
|
for port in "${port_list[@]}"; do
|
||||||
|
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||||
|
unique_ports+=("$port")
|
||||||
fi
|
fi
|
||||||
port_list+=("$new_port")
|
done
|
||||||
|
|
||||||
for port in "${port_list[@]}"; do
|
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
unique_ports=("$new_port")
|
||||||
unique_ports+=("$port")
|
fi
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
first_port="${unique_ports[0]}"
|
||||||
unique_ports=("$new_port")
|
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||||
fi
|
|
||||||
|
|
||||||
first_port="${unique_ports[0]}"
|
if [ ! -f "$env_file" ]; then
|
||||||
local ports_csv
|
cat > "$env_file" <<EOF
|
||||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
|
||||||
|
|
||||||
if [ ! -f "$env_file" ]; then
|
|
||||||
cat > "$env_file" <<EOF
|
|
||||||
NUITKA_BUILD=0
|
NUITKA_BUILD=0
|
||||||
INVENTAR_HTTP_PORT=$first_port
|
INVENTAR_HTTP_PORT=$first_port
|
||||||
INVENTAR_HTTP_PORTS=$ports_csv
|
INVENTAR_HTTP_PORTS=$ports_csv
|
||||||
EOF
|
EOF
|
||||||
|
else
|
||||||
|
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||||
|
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||||
else
|
else
|
||||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
fi
|
||||||
else
|
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||||
fi
|
else
|
||||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
|
||||||
else
|
|
||||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Updated runtime env ports: $ports_csv"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Updated runtime env ports: $ports_csv"
|
||||||
}
|
}
|
||||||
|
|
||||||
sync_tenant_port_map() {
|
sync_tenant_port_map() {
|
||||||
@@ -424,7 +388,6 @@ restart_app_container() {
|
|||||||
|
|
||||||
ensure_runtime_config_json
|
ensure_runtime_config_json
|
||||||
|
|
||||||
# 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"
|
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")" )
|
||||||
@@ -435,7 +398,6 @@ restart_app_container() {
|
|||||||
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
|
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Normal case: called directly from host
|
|
||||||
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
||||||
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
||||||
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
||||||
@@ -445,7 +407,6 @@ restart_app_container() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 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
|
||||||
@@ -555,24 +516,83 @@ remove_runtime_port() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
local HEADER='\033[95m'
|
||||||
|
local BLUE='\033[94m'
|
||||||
|
local GREEN='\033[92m'
|
||||||
|
local YELLOW='\033[93m'
|
||||||
|
local RED='\033[91m'
|
||||||
|
local BOLD='\033[1m'
|
||||||
|
local RESET='\033[0m'
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
${HEADER}${BOLD}=== MULTI-TENANT INVENTAR MANAGER ===${RESET}
|
||||||
|
${YELLOW}Nutzung:${RESET} $0 <befehl> [tenant_id] [optionen]
|
||||||
|
|
||||||
|
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
|
||||||
|
${GREEN}add${RESET} <tenant_id> [port]
|
||||||
|
Legt einen neuen Tenant an, registriert den Port und initialisiert
|
||||||
|
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}).
|
||||||
|
|
||||||
|
${GREEN}trial${RESET} <tenant_id> [port] [tage]
|
||||||
|
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage.
|
||||||
|
Läuft automatisch ab und löscht sich selbst.
|
||||||
|
|
||||||
|
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
|
||||||
|
Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank.
|
||||||
|
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
|
||||||
|
|
||||||
|
${GREEN}restart-tenant${RESET} <tenant_id>
|
||||||
|
Startet einen spezifischen Tenant neu, indem alle aktiven Sessions
|
||||||
|
und der Cache in der MongoDB geleert werden (Sitzungs-Reset).
|
||||||
|
|
||||||
|
${GREEN}restart-all${RESET}
|
||||||
|
Führt einen Zero-Downtime Rolling-Restart für alle App-Container durch.
|
||||||
|
|
||||||
|
${GREEN}list${RESET}
|
||||||
|
Listet alle registrierten Tenants aus der 'config.json' sowie alle
|
||||||
|
aktiven Tenant-Datenbanken aus der MongoDB auf.
|
||||||
|
|
||||||
|
${GREEN}module${RESET} <tenant_id> <modulname>=<on|off> ...
|
||||||
|
Aktiviert oder deaktiviert bestimmte Features/Module für einen Tenant.
|
||||||
|
Es können mehrere Module gleichzeitig übergeben werden.
|
||||||
|
|
||||||
|
${BLUE}${BOLD}GLOBALE OPTIONEN:${RESET}
|
||||||
|
${GREEN}-h, --help${RESET}
|
||||||
|
Zeigt diese Hilfe an.
|
||||||
|
|
||||||
|
${YELLOW}Beispiele:${RESET}
|
||||||
|
$0 add schule_muenchen 8081
|
||||||
|
$0 trial test_user 8082 14
|
||||||
|
$0 module schule_muenchen barre_code=on leih_historie=off
|
||||||
|
$0 remove -y test_user
|
||||||
|
|
||||||
|
-----------------------------------------------------------------
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
if [ -z "${1:-}" ]; then
|
if [ -z "${1:-}" ]; then
|
||||||
show_help
|
show_help
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COMMAND="$1"
|
COMMAND="$1"
|
||||||
TENANT_ID="${2:-}"
|
|
||||||
|
# === MAIN ROUTING BLOCK ===
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
-h|--help)
|
-h|--help)
|
||||||
show_help
|
show_help
|
||||||
;;
|
;;
|
||||||
add)
|
add)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PORT_ARG="$3"
|
PORT_ARG="${3:-}"
|
||||||
if [ -n "$PORT_ARG" ]; then
|
if [ -n "$PORT_ARG" ]; then
|
||||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||||
echo "Error: Port must be a numeric value."
|
echo "Error: Port must be a numeric value."
|
||||||
@@ -587,68 +607,13 @@ case "$COMMAND" in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Adding new tenant '$TENANT_ID'..."
|
echo "Adding new tenant '$TENANT_ID'..."
|
||||||
# Initialize tenant database via Python inside container
|
|
||||||
echo "Initializing database for $TENANT_ID..."
|
echo "Initializing database for $TENANT_ID..."
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
initialize_tenant_database "$TENANT_ID" "standard"
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
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': {
|
|
||||||
'can_borrow': True,
|
|
||||||
'can_insert': True,
|
|
||||||
'can_edit': True,
|
|
||||||
'can_delete': True,
|
|
||||||
'can_manage_users': True,
|
|
||||||
'can_manage_settings': True,
|
|
||||||
'can_view_logs': True,
|
|
||||||
},
|
|
||||||
'PagePermissions': {
|
|
||||||
'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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
|
||||||
" "$TENANT_ID"
|
|
||||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
|
||||||
else
|
|
||||||
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."
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
trial)
|
trial)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -679,68 +644,105 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
remove)
|
remove)
|
||||||
|
FORCE_REMOVE=false
|
||||||
|
TENANT_ARG="${2:-}"
|
||||||
|
|
||||||
|
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
|
||||||
|
FORCE_REMOVE=true
|
||||||
|
TENANT_ID="${3:-}"
|
||||||
|
else
|
||||||
|
TENANT_ID="$TENANT_ARG"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id to remove."
|
echo "Error: Please provide a tenant_id to remove."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
|
||||||
read confirm
|
|
||||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
|
||||||
echo "Removing tenant '$TENANT_ID'..."
|
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
|
||||||
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()
|
|
||||||
db_name = ctx._get_db_name(sys.argv[1])
|
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
|
||||||
client.drop_database(db_name)
|
|
||||||
print(f'Database for tenant {sys.argv[1]} dropped.')
|
|
||||||
" "$TENANT_ID"
|
|
||||||
echo "Tenant '$TENANT_ID' database removed."
|
|
||||||
else
|
|
||||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
|
||||||
fi
|
|
||||||
|
|
||||||
port_to_remove=""
|
if [ "$FORCE_REMOVE" != true ]; then
|
||||||
|
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||||
|
read confirm
|
||||||
|
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||||
|
echo "Removal canceled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Removing tenant '$TENANT_ID'..."
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
port_to_remove=""
|
||||||
|
|
||||||
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
|
# Zuerst die Datenbank via PyMongo hart droppen (Erzwungenes Löschen)
|
||||||
|
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||||
|
import sys, re
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
sys.path.insert(0, '/app/Web')
|
||||||
|
from tenant import delete_tenant, get_tenant_config
|
||||||
|
from Web.modules.database import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
tenant_id = sys.argv[1]
|
||||||
|
tenant_cfg = get_tenant_config(tenant_id)
|
||||||
|
port = tenant_cfg.get('port')
|
||||||
|
|
||||||
|
# Datenbanknamen exakt rekonstruieren
|
||||||
|
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||||
|
db_name = f"inventar_{sanitized}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
client.drop_database(db_name)
|
||||||
|
print(f"MongoDB database '{db_name}' dropped successfully.", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not drop database '{db_name}': {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if not delete_tenant(tenant_id):
|
||||||
|
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if port is not None:
|
||||||
|
print(port)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo "Tenant '$TENANT_ID' database and config removed."
|
||||||
|
else
|
||||||
|
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||||
if [ -n "$port_to_remove" ]; then
|
:
|
||||||
remove_runtime_port "$port_to_remove"
|
|
||||||
sync_tenant_port_map
|
|
||||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
|
||||||
restart_app_container
|
|
||||||
fi
|
|
||||||
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
|
|
||||||
else
|
|
||||||
sync_tenant_port_map
|
|
||||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
|
||||||
restart_app_container
|
|
||||||
fi
|
|
||||||
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$port_to_remove" ]; then
|
||||||
|
remove_runtime_port "$port_to_remove"
|
||||||
|
fi
|
||||||
|
sync_tenant_port_map
|
||||||
|
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||||
|
restart_app_container
|
||||||
|
fi
|
||||||
|
if [ -n "$port_to_remove" ]; then
|
||||||
|
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
||||||
else
|
else
|
||||||
echo "Removal canceled."
|
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
restart-tenant)
|
restart-tenant)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
||||||
# To restart a single tenant without restarting the global python processes,
|
|
||||||
# we can invalidate their cache or drop their sessions collection to sign everyone out
|
|
||||||
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'); sys.path.insert(0, '/app/Web'); from Web.modules.database 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()
|
||||||
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
||||||
" "$TENANT_ID"
|
" "$TENANT_ID"
|
||||||
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
||||||
@@ -803,12 +805,12 @@ PY
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
module)
|
module)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
||||||
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pass all remaining arguments to python script
|
|
||||||
shift 2
|
shift 2
|
||||||
|
|
||||||
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
||||||
@@ -884,4 +886,4 @@ PY
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
@@ -15,3 +15,4 @@ openpyxl
|
|||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
pywebpush
|
pywebpush
|
||||||
py-vapid>=1.9.0
|
py-vapid>=1.9.0
|
||||||
|
beautifulsoup4
|
||||||
@@ -572,7 +572,7 @@ write_runtime_compose_override() {
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
working_dir: /app/Web
|
working_dir: /app/Web
|
||||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
image: ${APP_IMAGE_VALUE}
|
image: ${APP_IMAGE_VALUE}
|
||||||
build: null
|
build: null
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user