Compare commits

...

98 Commits

Author SHA1 Message Date
Aiirondev_dev 921915e92f new slight adjust of some namings in thze dropdown 2026-06-23 14:26:39 +02:00
Aiirondev_dev a4b0866293 permision changes for testing 2026-06-23 13:03:51 +02:00
Aiirondev_dev e10d86da4b manage te damaged Item ffield 2026-06-23 12:26:09 +02:00
Aiirondev_dev f160f23e9c sdmal changes 2026-06-23 12:01:23 +02:00
Aiirondev_dev ca272542de Merge remote-tracking branch 'refs/remotes/origin/main' 2026-06-22 00:21:26 +02:00
Aiirondev_dev 7df9b65389 Changes from sha512 to scrypt to fit with DS-GVO 2026-06-22 00:21:15 +02:00
Aiirondev_dev e4af76c9c1 smal change of the upload layout and text, changes of the Student Barcode download to alow further scoolyears 2026-06-20 13:54:42 +02:00
Aiirondev_dev f5944bf090 implementation of the Item Media Type 2026-06-20 12:01:21 +02:00
Aiirondev_dev 4e68da2368 Second Changes to include the Scool name into the Bibliotheksausweis 2026-06-18 13:09:57 +02:00
Aiirondev_dev 0d6aca4e8c added the school_name to the Schülerausweis 2026-06-18 12:56:10 +02:00
Aiirondev_dev 2bb0cbe599 Implmentation of a autonmatic integration of the price of a book 2026-06-17 16:27:02 +02:00
Aiirondev_dev 339487b4d4 Implementierung des Preses bei Fallback 4 2026-06-17 16:14:38 +02:00
Aiirondev_dev 3f90e2d4f1 Fixes for the secondary information getting with isbn.de 2026-06-17 16:07:37 +02:00
Aiirondev_dev b238b52fbf impoved request handeling for ISBN 2026-06-17 15:55:21 +02:00
Aiirondev_dev 545dd7783c New implementation of a nother scanning methode for ISBN 2026-06-17 11:10:53 +02:00
Aiirondev_dev f4ce1cf3b4 license Changes 2026-06-12 23:45:49 +02:00
Aiirondev_dev 7bd12bddaa Titel adding to the client version oif the termin 2026-06-12 22:14:19 +02:00
Aiirondev_dev b8e23b94d2 final changes for the changes to add the Titel to the Terminplanes 2026-06-12 22:00:20 +02:00
Aiirondev_dev 06b32b4a3d temporary debug mode 2026-06-12 21:45:06 +02:00
Aiirondev_dev d24bd6ba56 smal fixes for a error in the variable passing in the backend after changes in blueprint and terminconfig 2026-06-12 20:59:25 +02:00
Aiirondev_dev d1f48cf184 Name fixes 2026-06-12 20:50:31 +02:00
Aiirondev_dev 32fccd17ec some minor fixes 2026-06-12 20:43:51 +02:00
Aiirondev_dev 01447220a0 Some smal fixes for the Terminplaner 2026-06-12 20:34:01 +02:00
Aiirondev_dev b3aeb289ef required thingis removed 2026-06-12 19:59:05 +02:00
Aiirondev_dev a6b90a6a9d fix of a spelling mistake 2026-06-12 19:50:01 +02:00
Aiirondev_dev 0a5d7dedea new implementation of the mail module relevant fuktions, in addition added thje funktion to add a title to the Terminplan 2026-06-12 19:43:37 +02:00
Aiirondev_dev 74e11b5370 Renamed the Filter Option 3 from Thema into Schlagwort for a better comprehension 2026-06-12 18:34:07 +02:00
Aiirondev_dev c0d6ad7b72 simpicity changes for the varible hardening 2026-06-12 00:27:04 +02:00
Aiirondev_dev 7da7152332 Fixes applied to the Barcode scanning 2026-06-12 00:14:20 +02:00
Aiirondev_dev 3c9ab099ba Slight fixes for the Scanning 2026-06-11 23:58:11 +02:00
Aiirondev_dev a81a8775a5 correction oif a tabulation error 2026-06-11 22:08:53 +02:00
Aiirondev_dev 1633118d7f center fix for the edit Modal 2026-06-10 12:48:14 +02:00
Aiirondev_dev a4b900c986 Fixes regarding ht eupload changes 2026-06-10 12:39:08 +02:00
Aiirondev_dev a06393a915 New implementiation of the Code Scannning mecanism 2026-06-10 12:26:25 +02:00
Aiirondev_dev 1b9bfe1b14 Fix of the edeting funktion in the Library Module 2026-06-10 11:24:33 +02:00
Aiirondev_dev 3cdd1552d9 Fix of a library funktion 2026-06-10 11:08:43 +02:00
Aiirondev_dev 80a206f524 Finisched the removal of the Author 2026-06-07 23:16:05 +02:00
Aiirondev_dev 3217da8b15 Improved ISBN Funktionality 2026-06-07 22:57:32 +02:00
Aiirondev_dev 22bc7c9c3c upload barcode implementation 2026-06-07 22:28:04 +02:00
Aiirondev_dev f156cc178a library author removing 2026-06-07 22:21:01 +02:00
Aiirondev_dev 116261d334 Slight fixes of thew Code scanning and Items processing 2026-06-07 12:42:27 +02:00
Aiirondev_dev 042afc19e2 slight changes to the Bar code scanner 2026-06-07 12:33:13 +02:00
Aiirondev_dev 28d049b52a smal changes fixes for the library Code Scanner 2026-06-07 00:14:57 +02:00
Aiirondev_dev 8e478aeba4 slight fixes 2026-06-07 00:03:21 +02:00
Aiirondev_dev 1b38d1f0c8 changed the wrapper of the new code scanning library 2026-06-06 23:52:19 +02:00
Aiirondev_dev e7e24b3fae further changes to the new Barcode system 2026-06-06 23:41:37 +02:00
Aiirondev_dev fc80857fbe minor changes to the Inpout scanning 2026-06-06 23:31:50 +02:00
Aiirondev_dev f763ad064d Changes from the deprecated qrcode scanning tool, to a more modern option in the library 2026-06-06 22:46:50 +02:00
Aiirondev_dev bc94ffdc18 further fixes 2026-06-06 17:17:15 +02:00
Aiirondev_dev 76e9670cee implementation of a new Framework for the Barcode scanning via Quagga2 -> only test implementation 2026-06-06 16:53:11 +02:00
Aiirondev_dev 577c6c4bed Removing more of the broken funktionaliti 2026-06-06 15:39:51 +02:00
Aiirondev_dev 3aa19b10d1 Reverted Faulti changes that have proven not worth implementing 2026-06-06 15:03:43 +02:00
Aiirondev_dev ff742d018e removed obsolet file 2026-06-01 14:34:05 +02:00
Aiirondev_dev 6fa40f26b0 Generall fixes, to manage the unused overhead 2026-06-01 13:23:52 +02:00
Aiirondev_dev 777fc81064 fix for the latest Tag 2026-06-01 13:20:10 +02:00
Aiirondev_dev cf5e38e319 removed development release version 2026-06-01 13:15:54 +02:00
Aiirondev_dev b76941d5fe Fix in the release versioning 2026-06-01 13:07:52 +02:00
Aiirondev_dev f46f2674b0 Library style adjustments for mobile 2026-06-01 12:51:28 +02:00
github-actions[bot] 8ebbdcfd54 chore: bump version to v0.8.31.1 2026-06-01 10:35:56 +00:00
Aiirondev_dev 92b5235035 feat: Improve mobile video playback for barcode scanning with autoplay and mute settings 2026-06-01 12:32:42 +02:00
github-actions[bot] 4bda2e044b chore: bump version to v0.8.30 2026-06-01 10:23:51 +00:00
Aiirondev_dev b83b6c0ba2 feat: Enhance barcode scanner with improved mobile compatibility and error handling 2026-06-01 12:22:54 +02:00
github-actions[bot] ab718d6ac2 chore: bump version to v0.8.29 2026-06-01 10:12:29 +00:00
Aiirondev_dev 112d9b6aa0 feat: Make video elements visible for barcode scanning in various templates 2026-06-01 12:10:31 +02:00
github-actions[bot] 5139a50a43 chore: bump version to v0.8.28 2026-06-01 09:56:08 +00:00
Aiirondev_dev 997c7b42d5 feat: Implement hybrid barcode scanner with real-time camera support and image upload fallback 2026-06-01 11:55:28 +02:00
github-actions[bot] 20e5d3bb9a chore: bump version to v0.8.27 2026-06-01 07:01:33 +00:00
Aiirondev_dev f62b22c2f7 feat: Refactor appointment handling to support multi-day events and improve calendar visibility 2026-06-01 09:00:35 +02:00
github-actions[bot] e76d525e4b chore: bump version to v0.8.26 2026-06-01 06:51:36 +00:00
Aiirondev_dev 7117dac67c feat: Enhance calendar view by switching to week view for multi-day events 2026-06-01 08:51:09 +02:00
Aiirondev_dev 5313c507ed feat: Remove background gap events from calendar display for improved visibility of available slots 2026-06-01 08:49:01 +02:00
github-actions[bot] 419fed4492 chore: bump version to v0.8.25 2026-06-01 06:39:43 +00:00
Aiirondev_dev c7112c7a42 feat: Update appointment time range to 08:15 - 20:00 and enhance visual representation of available slots 2026-06-01 08:39:20 +02:00
Aiirondev_dev fb73c9d4e7 feat: Extend time range for appointment slots and add background gap events for unavailable times 2026-06-01 08:30:36 +02:00
github-actions[bot] 8414e27f25 chore: bump version to v0.8.24 2026-06-01 05:38:10 +00:00
Aiirondev_dev 5903f2afdd feat: Refactor appointment retrieval to use dedicated appointment collection and improve time range extraction 2026-06-01 07:37:36 +02:00
github-actions[bot] a31fb4c048 chore: bump version to v0.8.23 2026-05-31 17:21:17 +00:00
Aiirondev_dev 46da45d373 feat: Enhance client booking experience with booking name visibility and success page 2026-05-31 19:20:40 +02:00
Aiirondev_dev 0b1bcef985 feat: Update booking modal with new confirmation buttons and ICS download functionality 2026-05-31 19:11:56 +02:00
github-actions[bot] 99ad9d4f79 chore: bump version to v0.8.22 2026-05-31 17:05:10 +00:00
Aiirondev_dev 89c1a525d8 feat: Enhance calendar functionality with multi-day view and time range selection 2026-05-31 19:04:43 +02:00
github-actions[bot] 9701805552 chore: bump version to v0.8.21 2026-05-31 16:54:47 +00:00
Aiirondev_dev 4227934252 feat: Add ICS export functionality for client slot bookings and enhance client booking interface 2026-05-31 18:54:21 +02:00
Aiirondev_dev d6883d1879 refactor: remove unused date and time parsing functions from backend_server.py and clean up termin_client.html 2026-05-31 18:50:31 +02:00
github-actions[bot] f47e133c45 chore: bump version to v0.8.20 2026-05-31 16:43:58 +00:00
Aiirondev_dev dec3c16d7f feat: Enhance appointment booking interface with date and time selection features 2026-05-31 18:43:04 +02:00
github-actions[bot] 897b2e43ad chore: bump version to v0.8.19 2026-05-31 16:33:17 +00:00
Aiirondev_dev 6dd44508ce fix: Ensure numeric fields are correctly cast to int in get_available function 2026-05-31 18:32:42 +02:00
github-actions[bot] 204ded6c7c chore: bump version to v0.8.18 2026-05-31 16:16:31 +00:00
Aiirondev_dev fbd2168aff feat: Add debug script for tenant-aware appointment lookup 2026-05-31 18:15:53 +02:00
github-actions[bot] 249a2bc2da chore: bump version to v0.8.17 2026-05-31 14:13:47 +00:00
Aiirondev_dev 7ab8f841d0 feat: Enhance tenant management and appointment deletion functionality 2026-05-31 16:13:11 +02:00
github-actions[bot] 43c544244c chore: bump version to v0.8.16 2026-05-31 13:47:10 +00:00
Aiirondev_dev 73e4407d7b feat: Implement upcoming appointments feature for users
- Added `remove_done` function to clean up finished appointments in the database.
- Introduced `get_upcoming_for_user` function to retrieve upcoming appointments for a specific user.
- Updated `backend_server.py` to include a new endpoint for fetching user-specific upcoming events.
- Modified `blueprint.py` to render upcoming events in the user interface.
- Enhanced `terminplan.html` to display a list of upcoming appointments with relevant details and action links.
- Updated configuration to enable the new appointment planning feature.
2026-05-31 15:46:22 +02:00
github-actions[bot] 94fc01c08a chore: bump version to v0.8.15 2026-05-30 12:55:19 +00:00
Aiirondev_dev 788ec1db62 feat: update terminology in calendar interface for clarity and consistency 2026-05-30 14:54:40 +02:00
github-actions[bot] 725584a104 chore: bump version to v0.8.14 2026-05-30 12:46:44 +00:00
Aiirondev_dev fbcc3b4522 feat: enhance calendar loading experience with skeleton UI 2026-05-30 14:46:22 +02:00
27 changed files with 2983 additions and 2164 deletions
+1 -2
View File
@@ -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
+2
View File
@@ -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:
+14 -89
View File
@@ -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
View File
@@ -1 +1 @@
v0.8.13 v0.8.31.1
+478 -124
View File
@@ -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
@@ -1267,7 +1269,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,
@@ -1733,7 +1736,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)
@@ -1993,7 +1995,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'],
@@ -2884,6 +2886,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')
@@ -2926,6 +2929,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(),
@@ -2946,6 +2950,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
) )
@@ -4118,10 +4123,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)
@@ -4145,7 +4153,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)
@@ -4285,10 +4297,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)
@@ -4803,6 +4817,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():
""" """
@@ -5011,6 +5115,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
@@ -5090,6 +5195,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'):
@@ -5116,55 +5223,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
@@ -7857,7 +7996,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-------------------------------------------------------"""
@@ -7920,6 +8060,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}")
@@ -8735,6 +8876,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}")
@@ -9285,17 +9427,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
@@ -9303,79 +9693,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:
@@ -9926,10 +10271,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)
@@ -9995,6 +10348,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}")
+77 -8
View File
@@ -22,6 +22,14 @@ from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId from bson.objectid import ObjectId
import datetime import datetime
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."""
base_query = {'Deleted': {'$ne': True}} base_query = {'Deleted': {'$ne': True}}
@@ -30,10 +38,10 @@ def _active_record_query(extra_query=None):
return base_query return base_query
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): 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=""):
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 = {
@@ -45,6 +53,7 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'user': user, 'user': user,
'mail': mail, 'mail': mail,
'note': note, 'note': note,
'title': title,
'calendar_enabled': bool(calendar_enabled), 'calendar_enabled': bool(calendar_enabled),
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable 'slots_booked': [], # -> [(start_time, name), ...]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(),
@@ -68,7 +77,7 @@ def get_item(id):
""" """
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() client.close()
@@ -90,11 +99,11 @@ def update(id,slots_used: list):
""" """
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': slots_used,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now()
} }
@@ -124,7 +133,7 @@ def remove_slot(id, date_start_time, name):
""" """
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) # Attempt to pull the exact element (stored as an array/tuple)
@@ -152,15 +161,75 @@ def remove(id):
""" """
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() client.close()
return result.modified_count > 0 return result.deleted_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
def remove_done():
"""removose already finisched appointments"""
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')
removed_count = 0
cursor = items.find(
_active_record_query(
{
'date_end': {'$lt': today},
}
)
).sort('date_start', 1)
for item in cursor:
item['_id'] = str(item.get('_id'))
result = items.delete_one({'_id': ObjectId(item['_id'])})
removed_count += result.deleted_count
client.close()
return removed_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
return False
def get_upcoming_for_user(user: str, limit: int = 25):
"""Return upcoming appointment plans for a user ordered by start date."""
remove_done()
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')
cursor = items.find(
_active_record_query(
{
'user': str(user or '').strip(),
'date_end': {'$gte': today},
}
)
).sort('date_start', 1)
results = []
for item in cursor:
item['_id'] = str(item.get('_id'))
results.append(item)
if len(results) >= max(1, int(limit)):
break
client.close()
return results
except Exception as e:
print(f"Error retrieving upcoming appointments: {e}")
return []
+3 -2
View File
@@ -432,7 +432,7 @@ def check_password_strength(password):
def hashing(password): def hashing(password):
""" """
Hash a password using SHA-512. Hash a password using scrypt.
Args: Args:
password (str): Password to hash password (str): Password to hash
@@ -440,7 +440,8 @@ def hashing(password):
Returns: Returns:
str: Hexadecimal digest of the hashed password str: Hexadecimal digest of the hashed password
""" """
return hashlib.sha512(password.encode()).hexdigest() hashed = hashlib.scrypt(password.encode(), salt=b'some_salt', n=16384, r=8, p=1)
return hashed.hex()
def check_nm_pwd(username, password): def check_nm_pwd(username, password):
+19 -15
View File
@@ -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
+200 -91
View File
@@ -3,13 +3,39 @@ Class for all funktions of the executive -> Lehrer
""" """
import datetime import datetime
from datetime import timedelta from datetime import timedelta
from flask import url_for from flask import url_for, has_request_context, request
import Web.modules.emailservice.email as mail_service 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 Web.tenant import get_tenant_context from Web.tenant import get_tenant_context
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):
if isinstance(time_span, list): if isinstance(time_span, list):
return [str(entry).strip() for entry in time_span if str(entry).strip()] return [str(entry).strip() for entry in time_span if str(entry).strip()]
@@ -56,15 +82,15 @@ def build_calendar_ics(appointment_id: str) -> str | None:
time_span = item.get('time_span', []) or [] time_span = item.get('time_span', []) or []
creator = item.get('user', 'Terminplaner') creator = item.get('user', 'Terminplaner')
note = item.get('note', '') or '' note = item.get('note', '') or ''
titel = item.get('title', '') or ''
tenant_id = _current_tenant_id()
try: try:
link = url_for('terminplaner.client', appointment_id=str(appointment_id), _external=True) link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
except Exception: except Exception:
tenant_context = get_tenant_context() host = _resolve_public_base_url()
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 "https://invario.eu"
link = host + "/terminplaner/client/" + str(appointment_id) link = host + "/terminplaner/client/" + str(appointment_id)
if tenant_id:
link += f"?tenant={tenant_id}"
try: try:
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date() start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
@@ -83,6 +109,8 @@ def build_calendar_ics(appointment_id: str) -> str | None:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span)) description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note: if note:
description_lines.append('Notiz: ' + str(note)) description_lines.append('Notiz: ' + str(note))
if titel:
description_lines.append('Titel: ' + str(titel))
ics_lines = [ ics_lines = [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
@@ -98,6 +126,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
f'URL:{_escape_ics_text(link)}', f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}', f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}', f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
f'Titel:{_escape_ics_text(titel)}',
'END:VEVENT', 'END:VEVENT',
'END:VCALENDAR', 'END:VCALENDAR',
'', '',
@@ -105,48 +134,111 @@ def build_calendar_ics(appointment_id: str) -> str | None:
return '\r\n'.join(ics_lines) return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict: 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."""
Generates a link for the executive to send to his clients to book a time Slot item = termin.get_item(appointment_id)
if not item:
Input: return None
- 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, calendar_enabled=calendar_enabled)
id_str = str(id)
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
try: try:
link = url_for('terminplaner.client', appointment_id=id_str, _external=True) start_dt = datetime.datetime.strptime(str(slot_start).strip(), '%Y-%m-%d %H:%M')
except Exception: except Exception:
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu" 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.utcnow().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="") -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
"""
try:
slots_int = int(slots)
except (ValueError, TypeError):
slots_int = 0
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)
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 link = host + "/terminplaner/client/" + id_str
subject = f"Terminanfrage von {user}" 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}" note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
calendar_link = None calendar_link = None
if calendar_enabled: if calendar_enabled:
try: try:
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, _external=True) calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
except Exception: except Exception:
tenant_context = get_tenant_context() host = _resolve_public_base_url()
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 "https://invario.eu"
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics" calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
if tenant_id:
calendar_link += f"?tenant={tenant_id}"
email_body = note_link email_body = note_link
if calendar_link: if calendar_link:
@@ -163,19 +255,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
def book_slot(id, date_start_time, name): 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
item = termin.get_item(id) item = termin.get_item(id)
if not item: if not item:
return False return False
@@ -193,10 +273,7 @@ def book_slot(id, date_start_time, name):
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)
slots.append((date_start_time, name)) slots.append((date_start_time, name))
# Update the appointment in the database
success = termin.update(id, slots) success = termin.update(id, slots)
return bool(success) return bool(success)
except Exception as e: except Exception as e:
@@ -205,19 +282,12 @@ def book_slot(id, date_start_time, name):
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
@@ -226,7 +296,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:
@@ -241,9 +310,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:
@@ -251,17 +317,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:
@@ -270,11 +325,19 @@ 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) try:
total_slots = int(termin_range.get('slots', 0) or 0)
except Exception:
total_slots = 0
try:
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
except Exception:
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
booked = termin_range.get('slots_booked', []) or [] booked = termin_range.get('slots_booked', []) or []
# Normalize booked entries to dicts for easier consumption
normalized = [] normalized = []
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:
@@ -285,13 +348,17 @@ def get_available(id):
normalized.append({'value': s}) normalized.append({'value': s})
slots_used = len(normalized) slots_used = len(normalized)
slots_left = max(0, total_slots - slots_used) try:
slots_left = max(0, int(total_slots) - slots_used)
except Exception:
slots_left = max(0, slots_used - slots_used)
return { return {
'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,
'slots_booked': normalized, 'slots_booked': normalized,
'slots_left': slots_left, 'slots_left': slots_left,
@@ -301,15 +368,57 @@ def get_available(id):
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:
- id: id of the appointment
Output: def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame, user_name = str(user or '').strip()
second day Time frame, third etc.), slot lenght, (bookedslots -> list)] if not user_name:
""" return []
return get_available(id)
appointments = termin.get_upcoming_for_user(user_name, limit=limit)
host = _resolve_public_base_url()
tenant_id = _current_tenant_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 ''),
}
)
return result
+133 -9
View File
@@ -2,6 +2,8 @@ from flask import Blueprint, render_template, request, session, url_for, redirec
from flask import Response from flask import Response
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
# Create a blueprint instance # Create a blueprint instance
appoint_bp = Blueprint('terminplaner', __name__) appoint_bp = Blueprint('terminplaner', __name__)
@@ -21,6 +23,17 @@ def _appointment_not_found_response():
error_message='Der Termin wurde nicht gefunden.', error_message='Der Termin wurde nicht gefunden.',
), 404 ), 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>', methods=['POST', 'GET']) @appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
def client(appointment_id): def client(appointment_id):
""" """
@@ -34,6 +47,28 @@ def client(appointment_id):
if not available: if not available:
return _appointment_not_found_response() return _appointment_not_found_response()
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_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)
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') start_daytime = request.form.get('start_day_time')
username = request.form.get('client_name') username = request.form.get('client_name')
@@ -42,23 +77,80 @@ def client(appointment_id):
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,
) )
if appointment_service.book_slot(appointment_id, start_daytime, username): if appointment_service.book_slot(appointment_id, start_daytime, username):
flash('Der Termin wurde gespeichert.', 'success') return redirect(
return redirect(url_for('terminplaner.client', appointment_id=appointment_id)) 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.', 'error') flash('Der Termin konnte nicht gespeichert werden.', '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,
) )
@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():
""" """
@@ -77,12 +169,14 @@ 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') # Korrigiert: 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' add_to_calendar = request.form.get('add_to_calendar') == 'on'
title = request.form.get('title', '').strip() # Korrigiert: title statt titel
if not start or not end or not time or not slots_amount or not slot_lenght: # Abfrage ebenfalls auf title und slot_length angepasst
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( return render_template(
'termin_configure.html', 'termin_configure.html',
@@ -91,7 +185,8 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
) )
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar) # Variablen im Funktionsaufruf aktualisiert
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title)
flash('Der Terminplan wurde angelegt.', 'success') flash('Der Terminplan wurde angelegt.', 'success')
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
@@ -100,6 +195,7 @@ def configure():
calendar_link=result.get('calendar_link'), calendar_link=result.get('calendar_link'),
add_to_calendar=add_to_calendar, add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=title,
) )
elif request.method == "GET": elif request.method == "GET":
return render_template( return render_template(
@@ -109,6 +205,7 @@ def configure():
calendar_link=None, calendar_link=None,
add_to_calendar=False, add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=None,
) )
@@ -122,8 +219,29 @@ def calendar_export(appointment_id):
if not ics_content: if not ics_content:
return _appointment_not_found_response() 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 = Response(ics_content, mimetype='text/calendar; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics' 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 return response
@appoint_bp.route('/') @appoint_bp.route('/')
@@ -132,8 +250,14 @@ 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
View File
@@ -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
+31 -29
View File
@@ -1295,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>
@@ -1325,8 +1320,8 @@
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% 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('admin_borrowings', True) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
{% endif %} {% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %} {% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
@@ -1392,28 +1387,7 @@
{% 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) %}
@@ -1427,6 +1401,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); }
@@ -1560,6 +1539,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
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+6 -10
View File
@@ -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 -45
View File
@@ -373,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>
@@ -420,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">
@@ -525,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')
@@ -535,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
@@ -995,7 +1057,7 @@
<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>` : ''}
@@ -1620,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>
+129 -80
View File
@@ -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>
+465 -11
View File
@@ -2,17 +2,49 @@
{% 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> <h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
<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> <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>
<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>
@@ -27,12 +59,24 @@
<div>{{ available.slot_lenght }} Minuten</div> <div>{{ available.slot_lenght }} Minuten</div>
</div> </div>
<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>
{% 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">
{% for booking in available.slots_booked %} {% for booking in available.slots_booked %}
<li>{{ booking.start }}{% if booking.name %} - {{ booking.name }}{% endif %}</li> <li>
{{ booking.start }}
{% if can_view_booking_names and booking.name %}
- {{ booking.name }}
{% else %}
- Belegt
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -40,15 +84,26 @@
</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> <h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id) }}" class="vstack gap-3">
<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> <div>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label> <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="2026-05-29 10:30" required> <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">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div> <div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
</div> </div>
<div> <div>
<label for="client_name" class="form-label fw-semibold">Ihr Name</label> <label for="client_name" class="form-label fw-semibold">Ihr Name</label>
@@ -56,7 +111,7 @@
</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">
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button> <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') }}">Zur Übersicht</a> <a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
</div> </div>
</form> </form>
</div> </div>
@@ -66,4 +121,403 @@
</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 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 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;
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
return String(entry.start || '').trim();
}).filter(Boolean));
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;
}
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);
if (!bookedStarts.has(slotStart)) {
slots.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 || ''));
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
let slotMinTime = '08:15:00';
let slotMaxTime = '20:00:00';
if (candidateSlots.length > 0) {
const starts = candidateSlots.map(function (slot) {
return new Date(slot.start.replace(' ', 'T') + ':00');
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
const ends = candidateSlots.map(function (slot) {
return new Date(slot.end.replace(' ', 'T') + ':00');
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
// Keep the computed min/max for gap calculations below
var computedMinStart = null;
var computedMaxEnd = null;
if (starts.length > 0 && ends.length > 0) {
computedMinStart = starts[0];
computedMaxEnd = ends[0];
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
}
}
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 nicht als freier Slot 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 || '');
}
});
// No background greying: show full calendar skeleton and only mark possible slots
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);
}
}
// No background gaps: keep full skeleton/grid visible
// Add candidate free slots (blue)
candidateSlots.forEach(function (slot) {
const start = new Date(slot.start.replace(' ', 'T') + ':00');
const end = new Date(slot.end.replace(' ', 'T') + ':00');
calendar.addEvent({
title: 'Freier Slot',
start: start,
end: end,
color: '#0d6efd',
textColor: '#ffffff',
editable: false,
extendedProps: {
slotStart: slot.start,
slotType: 'free',
}
});
});
(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: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
start: start,
end: end,
color: '#dc3545',
editable: false,
display: 'block',
});
});
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]) {
if (calendar.view.type === 'timeGridDay') {
calendar.gotoDate(allDays[idx]);
}
}
});
calendar.render();
refreshIcsLink();
})();
</script>
{% endblock %} {% endblock %}
+121
View File
@@ -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>
+171 -54
View File
@@ -14,13 +14,17 @@
<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>
@@ -28,52 +32,63 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
<div> <div>
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label> <label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div> <div class="form-text mb-0">Die Start-, End- und Pausenzeiten werden auf alle erzeugten Tage angewandt.</div>
</div> </div>
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button> <button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
</div> </div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-sm-6 col-lg-3">
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label> <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"> <input type="time" id="default_day_start" class="form-control" value="08:00">
</div> </div>
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-sm-6 col-lg-3">
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label> <label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
<input type="time" id="default_day_end" class="form-control" value="12:00"> <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> </div>
<div id="time_frame_days" class="vstack gap-2"></div> <div id="time_frame_days" class="vstack gap-2"></div>
<div class="mt-3"> <div class="mt-3">
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label> <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" required></textarea> <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">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div> <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>
<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>
<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> </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>
<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>
</div>
<div>
<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>
</div>
{% endif %}
<div class="form-check"> <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 %}> <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> <label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
@@ -92,7 +107,7 @@
<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"> <div class="mb-2">
{% if email_service_enabled %} {% if mail_service_enabled %}
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail. Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
{% else %} {% else %}
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden. Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
@@ -117,30 +132,31 @@
const buildButton = document.getElementById('build_time_frame'); const buildButton = document.getElementById('build_time_frame');
const daysContainer = document.getElementById('time_frame_days'); const daysContainer = document.getElementById('time_frame_days');
const timeFrameTextarea = document.getElementById('time_frame'); const timeFrameTextarea = document.getElementById('time_frame');
const defaultStartInput = document.getElementById('default_day_start'); const defaultStartInput = document.getElementById('default_day_start');
const defaultEndInput = document.getElementById('default_day_end'); 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 slotsAmountsInput = document.getElementById('slots_amounts');
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) { if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
return; return;
} }
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', { const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
weekday: 'long', weekday: 'short',
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
}); });
function parseDate(value) { function parseDate(value) {
if (!value) { if (!value) return null;
return null;
}
const parts = value.split('-').map(Number); const parts = value.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) { if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
return null;
}
return new Date(parts[0], parts[1] - 1, parts[2]); return new Date(parts[0], parts[1] - 1, parts[2]);
} }
@@ -161,16 +177,92 @@
return copy; 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";
}
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
function calculateSlots() {
if (!slotLengthInput || !slotsAmountsInput) return;
const slotLength = parseInt(slotLengthInput.value, 10);
if (isNaN(slotLength) || slotLength <= 0) {
updateSlotsDisplay(0);
return;
}
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;
const startMins = timeToMinutes(startTime);
const endMins = timeToMinutes(endTime);
const pauseStartMins = timeToMinutes(pauseStart);
const pauseEndMins = timeToMinutes(pauseEnd);
if (endMins <= startMins) return; // Ungültige Zeit
// 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);
}
});
updateSlotsDisplay(totalSlots);
}
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
function syncTextarea() { function syncTextarea() {
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]')); const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
const lines = rows.map(function (row) { const lines = [];
rows.forEach(function (row) {
const dayValue = row.getAttribute('data-day-value') || ''; const dayValue = row.getAttribute('data-day-value') || '';
const startTime = row.querySelector('[data-time-start]')?.value || ''; const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || ''; const endTime = row.querySelector('[data-time-end]')?.value || '';
return `${dayValue} ${startTime}-${endTime}`.trim(); const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
}).filter(Boolean); 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'); timeFrameTextarea.value = lines.join('\n');
calculateSlots(); // Nach jedem Update neu berechnen
} }
function renderRows() { function renderRows() {
@@ -193,40 +285,57 @@
endDateInput.setCustomValidity(''); endDateInput.setCustomValidity('');
// Bisherige Werte sichern
const existingValues = new Map(); const existingValues = new Map();
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) { Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
const dayValue = row.getAttribute('data-day-value'); const dayValue = row.getAttribute('data-day-value');
const startTime = row.querySelector('[data-time-start]')?.value || ''; const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.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) { if (dayValue) {
existingValues.set(dayValue, { startTime, endTime }); existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
} }
}); });
const defaultStart = defaultStartInput.value || '08:00'; const defaultStart = defaultStartInput.value || '08:00';
const defaultEnd = defaultEndInput.value || '12:00'; const defaultEnd = defaultEndInput.value || '16:00';
const defaultPauseStart = defaultPauseStartInput.value || '';
const defaultPauseEnd = defaultPauseEndInput.value || '';
const rows = []; const rows = [];
for (let current = startDate; current <= endDate; current = addDays(current, 1)) { for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
const dayValue = formatDateForValue(current); const dayValue = formatDateForValue(current);
const preserved = existingValues.get(dayValue) || {}; const preserved = existingValues.get(dayValue) || {};
const rowStart = preserved.startTime || defaultStart;
const rowEnd = preserved.endTime || defaultEnd; 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(` rows.push(`
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}"> <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="row g-2 align-items-end">
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label> <label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
<div class="text-muted small">${dayValue}</div> <div class="text-muted small">${dayValue}</div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-2">
<label class="form-label mb-1">Von</label> <label class="form-label mb-1 text-muted small">Von</label>
<input type="time" class="form-control" value="${rowStart}" data-time-start> <input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-2">
<label class="form-label mb-1">Bis</label> <label class="form-label mb-1 text-muted small">Bis</label>
<input type="time" class="form-control" value="${rowEnd}" data-time-end> <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> </div>
</div> </div>
@@ -242,17 +351,25 @@
syncTextarea(); syncTextarea();
} }
// Event Listeners Registration
buildButton.addEventListener('click', renderRows); buildButton.addEventListener('click', renderRows);
startDateInput.addEventListener('input', renderRows);
startDateInput.addEventListener('change', renderRows); startDateInput.addEventListener('change', renderRows);
endDateInput.addEventListener('input', renderRows);
endDateInput.addEventListener('change', renderRows); endDateInput.addEventListener('change', renderRows);
defaultStartInput.addEventListener('change', syncTextarea);
defaultEndInput.addEventListener('change', syncTextarea); 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 (startDateInput.value && endDateInput.value) { if (startDateInput.value && endDateInput.value) {
renderRows(); renderRows();
} }
})(); })();
</script> </script>
{% endblock %} {% endblock %}
File diff suppressed because it is too large Load Diff
+47 -4
View File
@@ -14,8 +14,8 @@
<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>
@@ -27,7 +27,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 +37,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,6 +58,49 @@
<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>
<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">
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</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>
+344 -291
View File
@@ -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.&#10;ABC-001&#10;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 %}>
@@ -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>
+18
View File
@@ -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
+3
View File
@@ -53,6 +53,9 @@
"inventory": { "inventory": {
"enabled": true "enabled": true
}, },
"terminplan": {
"enabled": true
},
"library": { "library": {
"enabled": true "enabled": true
}, },
+1
View File
@@ -213,6 +213,7 @@ existing['modules'] = {
'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
+1
View File
@@ -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