Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| debd4dceb1 | |||
| db15df57c3 | |||
| bcfe2095d5 | |||
| 69ac6eca63 | |||
| 717e3ce15e | |||
| 007ae04bf3 | |||
| c0947c5cf1 | |||
| c1d2935ad6 | |||
| 9fde0b4b2a | |||
| da7b075cc4 | |||
| 347f258b8f | |||
| 2421f867ed | |||
| 921915e92f | |||
| a4b0866293 | |||
| e10d86da4b | |||
| f160f23e9c | |||
| ca272542de | |||
| 7df9b65389 | |||
| e4af76c9c1 | |||
| f5944bf090 | |||
| 4e68da2368 | |||
| 0d6aca4e8c | |||
| 2bb0cbe599 | |||
| 339487b4d4 | |||
| 3f90e2d4f1 | |||
| b238b52fbf | |||
| 545dd7783c | |||
| f4ce1cf3b4 | |||
| 7bd12bddaa | |||
| b8e23b94d2 | |||
| 06b32b4a3d | |||
| d24bd6ba56 | |||
| d1f48cf184 | |||
| 32fccd17ec | |||
| 01447220a0 | |||
| b3aeb289ef | |||
| a6b90a6a9d | |||
| 0a5d7dedea | |||
| 74e11b5370 | |||
| c0d6ad7b72 | |||
| 7da7152332 | |||
| 3c9ab099ba | |||
| a81a8775a5 | |||
| 1633118d7f | |||
| a4b900c986 | |||
| a06393a915 | |||
| 1b9bfe1b14 | |||
| 3cdd1552d9 | |||
| 80a206f524 | |||
| 3217da8b15 | |||
| 22bc7c9c3c | |||
| f156cc178a | |||
| 116261d334 | |||
| 042afc19e2 | |||
| 28d049b52a | |||
| 8e478aeba4 | |||
| 1b38d1f0c8 | |||
| e7e24b3fae | |||
| fc80857fbe | |||
| f763ad064d | |||
| bc94ffdc18 | |||
| 76e9670cee | |||
| 577c6c4bed | |||
| 3aa19b10d1 | |||
| ff742d018e | |||
| 6fa40f26b0 | |||
| 777fc81064 | |||
| cf5e38e319 | |||
| b76941d5fe | |||
| f46f2674b0 | |||
| 8ebbdcfd54 | |||
| 92b5235035 | |||
| 4bda2e044b | |||
| b83b6c0ba2 | |||
| ab718d6ac2 | |||
| 112d9b6aa0 | |||
| 5139a50a43 | |||
| 997c7b42d5 | |||
| 20e5d3bb9a | |||
| f62b22c2f7 | |||
| e76d525e4b | |||
| 7117dac67c | |||
| 5313c507ed | |||
| 419fed4492 | |||
| c7112c7a42 | |||
| fb73c9d4e7 | |||
| 8414e27f25 | |||
| 5903f2afdd | |||
| a31fb4c048 | |||
| 46da45d373 | |||
| 0b1bcef985 | |||
| 99ad9d4f79 | |||
| 89c1a525d8 | |||
| 9701805552 | |||
| 4227934252 | |||
| d6883d1879 | |||
| f47e133c45 | |||
| dec3c16d7f | |||
| 897b2e43ad | |||
| 6dd44508ce | |||
| 204ded6c7c | |||
| fbd2168aff | |||
| 249a2bc2da | |||
| 7ab8f841d0 | |||
| 43c544244c | |||
| 73e4407d7b | |||
| 94fc01c08a | |||
| 788ec1db62 | |||
| 725584a104 | |||
| fbcc3b4522 | |||
| adc2bad3fb | |||
| f7be7d9bf0 | |||
| 1bd2db41b4 | |||
| ac68beb80a | |||
| ea6826b304 | |||
| d668e54042 | |||
| 3fd00aec30 | |||
| ef2d1dfab0 | |||
| 1c5aa9beed | |||
| a7d3863fde | |||
| ac0e60fadb | |||
| cbeed68147 | |||
| 715d932dba | |||
| 606e1822d1 | |||
| c0ce80d223 | |||
| 96debef4f4 | |||
| c6c57e4b53 | |||
| 3dc9ecfe5b | |||
| 8e20580552 | |||
| aa47325e80 | |||
| 539cf2b21c | |||
| 4ae0d7f00e | |||
| 0b09fe489e | |||
| a98f3751e9 | |||
| d722b5a774 | |||
| 0edbef3edd | |||
| d325fa57e8 | |||
| 8a6a842e1a | |||
| b4d61e27f4 | |||
| 318e57475f | |||
| 6a72bd5d82 | |||
| 74fa0d8a68 | |||
| 451b7bd3c5 | |||
| 84543c8734 | |||
| 8a64a1adcd | |||
| 25b7fe986c | |||
| 4b376bc426 | |||
| e41d739636 | |||
| 4e73d69245 | |||
| daffeaf379 | |||
| a1ad354ccf | |||
| d4d5cd6436 | |||
| 9fd9d63e31 | |||
| 08cce1b884 | |||
| adde73dffb | |||
| da17667d90 | |||
| c69231fa77 | |||
| ac43178b29 | |||
| a2d58208e6 | |||
| f8171a5f18 | |||
| 6f898ffea4 | |||
| b29cc38a3d | |||
| 17745c64e0 | |||
| a0279f82e1 | |||
| 8d1e3865d3 |
+1
-2
@@ -8,5 +8,4 @@ INVENTAR_APP_MEM_SWAP_LIMIT=768m
|
||||
INVENTAR_WORKERS=4
|
||||
INVENTAR_THREADS=2
|
||||
INVENTAR_WORKER_TIMEOUT=30
|
||||
INVENTAR_WORKER_CONNECTIONS=100
|
||||
|
||||
INVENTAR_WORKER_CONNECTIONS=100
|
||||
@@ -1,5 +1,7 @@
|
||||
services:
|
||||
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
|
||||
build: null
|
||||
ports:
|
||||
|
||||
@@ -15,15 +15,6 @@ on:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
- development
|
||||
push_dev:
|
||||
description: "If true, push the :dev image to GHCR for development releases"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -48,13 +39,12 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
||||
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||
TAG="$REF_NAME"
|
||||
else
|
||||
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
||||
latest_tag="v3.0.0"
|
||||
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
|
||||
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
|
||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
if [ -n "$tag_name" ]; then
|
||||
@@ -67,7 +57,7 @@ jobs:
|
||||
minor=${BASH_REMATCH[2]}
|
||||
patch=${BASH_REMATCH[3]}
|
||||
else
|
||||
major=3; minor=0; patch=0
|
||||
major=0; minor=8; patch=31
|
||||
fi
|
||||
|
||||
# Bump strategy: major / minor / patch
|
||||
@@ -78,30 +68,28 @@ jobs:
|
||||
else
|
||||
patch=$((patch + 1))
|
||||
fi
|
||||
|
||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
||||
TAG="v${major}.${minor}.${patch}-dev"
|
||||
else
|
||||
TAG="v${major}.${minor}.${patch}"
|
||||
fi
|
||||
|
||||
# Zusammenbau des Tags für den manuellen Run
|
||||
TAG="v${major}.${minor}.${patch}"
|
||||
fi
|
||||
|
||||
# Validierung des erzeugten oder übergebenen Tags
|
||||
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
|
||||
fi
|
||||
|
||||
git fetch --tags --force
|
||||
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
LATEST_TAG="v3.0.0"
|
||||
LATEST_TAG="v0.8.31"
|
||||
fi
|
||||
|
||||
TAG_MAJOR="${TAG#v}"
|
||||
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
||||
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
||||
if [ -z "$LATEST_MAJOR" ]; then
|
||||
LATEST_MAJOR="3"
|
||||
LATEST_MAJOR="0"
|
||||
fi
|
||||
|
||||
# If not explicitly bumping major, disallow changing major version
|
||||
@@ -123,33 +111,6 @@ jobs:
|
||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
||||
echo "is_development=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_development=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
|
||||
echo "push_dev=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push_dev=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -162,26 +123,16 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push release image
|
||||
if: steps.meta.outputs.is_development != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}
|
||||
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||
|
||||
- name: Build and push development image (:dev)
|
||||
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
|
||||
|
||||
- name: Build local image tar for offline deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -204,30 +155,17 @@ jobs:
|
||||
docker build -t "$IMG" .
|
||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||
|
||||
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
|
||||
|
||||
- name: Commit .release-version for tag pushes
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
if ! git diff --quiet .release-version; then
|
||||
git add .release-version
|
||||
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
fi
|
||||
|
||||
- name: Create release-only docker bundle
|
||||
run: |
|
||||
mkdir -p release-bundle
|
||||
cat > release-bundle/docker-compose.yml <<EOF
|
||||
services:
|
||||
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
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
depends_on:
|
||||
- mongodb
|
||||
- redis
|
||||
@@ -300,17 +238,6 @@ jobs:
|
||||
fi
|
||||
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
|
||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||
@@ -319,10 +246,8 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
prerelease: ${{ steps.meta.outputs.is_development }}
|
||||
files: |
|
||||
inventarsystem-docker-bundle.tar.gz
|
||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||
inventarsystem-image-dev.tar.gz
|
||||
fail_on_unmatched_files: false
|
||||
generate_release_notes: true
|
||||
generate_release_notes: true
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.7.71
|
||||
v0.8.31.1
|
||||
|
||||
+1
-1
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
|
||||
WORKDIR /app/Web
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
|
||||
+1002
-199
File diff suppressed because it is too large
Load Diff
@@ -18,14 +18,6 @@ Collection Structure:
|
||||
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
|
||||
- Status fields: Verfuegbar, User (if currently borrowed)
|
||||
"""
|
||||
'''
|
||||
Copyright 2025-2026 AIIrondev
|
||||
|
||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||
See Legal/LICENSE for the full license text.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
@@ -71,6 +71,15 @@ DEFAULTS = {
|
||||
'logo_path': '',
|
||||
'logo_thumb': '',
|
||||
'logo_thumb': '',
|
||||
# School-level rollover configuration for advancing class years
|
||||
'rollover': {
|
||||
'month': 9,
|
||||
'day': 1,
|
||||
'hour': 3,
|
||||
'minute': 0,
|
||||
'max_class': 13,
|
||||
'graduate_label': ''
|
||||
},
|
||||
},
|
||||
'schoolPeriods': {
|
||||
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
|
||||
@@ -88,6 +97,9 @@ DEFAULTS = {
|
||||
'inventory': {
|
||||
'enabled': True
|
||||
},
|
||||
'terminplan': {
|
||||
'enabled': True
|
||||
},
|
||||
'library': {
|
||||
'enabled': False
|
||||
},
|
||||
@@ -100,9 +112,29 @@ DEFAULTS = {
|
||||
}
|
||||
|
||||
# Load configuration file
|
||||
CONFIG_PATH = os.path.join(BASE_DIR, '..', 'config.json')
|
||||
def _resolve_config_path():
|
||||
"""Resolve runtime config path with robust fallbacks for Docker deployments."""
|
||||
env_path = os.getenv('INVENTAR_CONFIG_PATH', '').strip()
|
||||
if env_path:
|
||||
return env_path
|
||||
|
||||
candidates = [
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(BASE_DIR))), 'config.json'),
|
||||
os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'config.json'),
|
||||
os.path.join(os.path.dirname(BASE_DIR), 'config.json'),
|
||||
os.path.join(BASE_DIR, '..', 'config.json'),
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
|
||||
return os.path.abspath(candidates[0])
|
||||
|
||||
|
||||
CONFIG_PATH = _resolve_config_path()
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
_conf = json.load(f)
|
||||
except Exception:
|
||||
_conf = {}
|
||||
@@ -135,10 +167,19 @@ def _get_int_env(name, default):
|
||||
return int(default)
|
||||
|
||||
def get_version():
|
||||
with open(os.path.join(BASE_DIR, '..', '..', '..', '.docker-build.env'), 'r') as f:
|
||||
for l in f:
|
||||
if l.startswith('INVENTAR_APP_IMAGE='):
|
||||
return l.split(':', 1)[1].strip()
|
||||
# Prefer an explicit release marker if present (created by release process).
|
||||
project_root = os.path.abspath(os.path.join(BASE_DIR, '..', '..', '..'))
|
||||
release_file = os.path.join(project_root, '.docker-build.env')
|
||||
try:
|
||||
if os.path.isfile(release_file):
|
||||
with open(release_file, 'r', encoding='utf-8') as f:
|
||||
var = f.readline()
|
||||
for i in var:
|
||||
i = i.split(":")
|
||||
i.pop[0]
|
||||
return str(i)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Expose settings
|
||||
APP_VERSION = get_version()
|
||||
@@ -178,10 +219,30 @@ SSL_ENABLED = _get(_conf, ['ssl', 'enabled'], DEFAULTS['ssl']['enabled'])
|
||||
SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
|
||||
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
|
||||
|
||||
# Email settings
|
||||
EMAIL_ENABLED = _get(_conf, ['email', 'enabled'], False)
|
||||
EMAIL_SMTP_HOST = _get(_conf, ['email', 'smtp_host'], 'smtp.gmail.com')
|
||||
EMAIL_SMTP_PORT = int(_get(_conf, ['email', 'smtp_port'], 587))
|
||||
EMAIL_USE_TLS = bool(_get(_conf, ['email', 'use_tls'], True))
|
||||
EMAIL_USERNAME = _get(_conf, ['email', 'username'], '')
|
||||
EMAIL_PASSWORD = _get(_conf, ['email', 'password'], '')
|
||||
EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
|
||||
EMAIL_DEFAULT_SENDER_NAME = _get(_conf, ['email', 'default_sender_name'], 'Inventarsystem')
|
||||
EMAIL_TIMEOUT_SECONDS = int(_get(_conf, ['email', 'timeout_seconds'], 30))
|
||||
|
||||
# School periods
|
||||
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
||||
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
|
||||
|
||||
# School rollover configuration (can be set in config.json under `school.rollover`)
|
||||
SCHOOL_ROLLOVER = _get(_conf, ['school', 'rollover'], DEFAULTS['school'].get('rollover', {}))
|
||||
SCHOOL_ROLLOVER_MONTH = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MONTH', str(SCHOOL_ROLLOVER.get('month', 9))))
|
||||
SCHOOL_ROLLOVER_DAY = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_DAY', str(SCHOOL_ROLLOVER.get('day', 1))))
|
||||
SCHOOL_ROLLOVER_HOUR = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_HOUR', str(SCHOOL_ROLLOVER.get('hour', 3))))
|
||||
SCHOOL_ROLLOVER_MIN = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MIN', str(SCHOOL_ROLLOVER.get('minute', 0))))
|
||||
SCHOOL_ROLLOVER_MAX_CLASS = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', str(SCHOOL_ROLLOVER.get('max_class', 13))))
|
||||
SCHOOL_ROLLOVER_GRADUATE_LABEL = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', str(SCHOOL_ROLLOVER.get('graduate_label', '')))
|
||||
|
||||
# Optional feature modules
|
||||
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
|
||||
|
||||
@@ -214,17 +275,23 @@ class _TenantAwareBool:
|
||||
from Web.modules.module_registry import registry as MODULES
|
||||
|
||||
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
|
||||
TERMINPLAN_MODULE_ENABLED = _TenantAwareBool('terminplan', _get(_conf, ['modules', 'terminplan', 'enabled'], DEFAULTS['modules']['terminplan']['enabled']))
|
||||
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
|
||||
def _match_inventory(path):
|
||||
if not path: return False
|
||||
if path == '/' or path.startswith('/home'): return True
|
||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items', '/terminplan'))
|
||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
|
||||
|
||||
def _match_terminplan(path):
|
||||
if not path:
|
||||
return False
|
||||
return path.startswith(('/terminplan', '/terminplaner'))
|
||||
|
||||
def _match_library(path):
|
||||
if not path: return False
|
||||
return path.startswith(('/library', '/library_', '/student_cards'))
|
||||
return path.startswith(('/library', '/library_', '/student_cards', '/admin_damaged_items', '/admin_borrowings', '/admin/library'))
|
||||
|
||||
def _match_student_cards(path):
|
||||
if not path: return False
|
||||
@@ -232,6 +299,7 @@ def _match_student_cards(path):
|
||||
|
||||
# Register core modules into the pipeline
|
||||
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
|
||||
MODULES.register('terminplan', TERMINPLAN_MODULE_ENABLED, _match_terminplan)
|
||||
MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
|
||||
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Apointment Managment
|
||||
=========================
|
||||
|
||||
This module manages appointments in the database. It provides comprehensive
|
||||
functionality for creating, updating, retrieving appointments items.
|
||||
|
||||
Key Features:
|
||||
- Creating and updating appointments
|
||||
- Retrieving items by ID
|
||||
- Managing time slots
|
||||
- client retrival
|
||||
|
||||
Collection Structure:
|
||||
- appointments:
|
||||
- Required fields: user, start_date, end_date, daytime, slots, slot_time
|
||||
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
|
||||
- Status fields: slots_used_by
|
||||
"""
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
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):
|
||||
"""Build a query that excludes logically deleted records."""
|
||||
base_query = {'Deleted': {'$ne': True}}
|
||||
if extra_query:
|
||||
base_query.update(extra_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, title: str=""):
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
item = {
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'time_span': time_span,
|
||||
'slots': slots,
|
||||
'slot_lenght': slot_lenght,
|
||||
'user': user,
|
||||
'mail': mail,
|
||||
'note': note,
|
||||
'title': title,
|
||||
'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
|
||||
'Created': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
result = items.insert_one(item)
|
||||
return result.inserted_id
|
||||
except Exception as e:
|
||||
print(f"Exception accured: {e}")
|
||||
|
||||
|
||||
def get_item(id):
|
||||
"""
|
||||
Retrieve a specific appointment by its ID.
|
||||
|
||||
Args:
|
||||
id (str): ID of the appointsment to retrieve
|
||||
|
||||
Returns:
|
||||
dict: The appointment document or None if not found
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
||||
client.close()
|
||||
return item
|
||||
except Exception as e:
|
||||
print(f"Error retrieving item: {e}")
|
||||
return None
|
||||
|
||||
def update(id,slots_used: list):
|
||||
"""
|
||||
Update an existing appointment.
|
||||
|
||||
Args:
|
||||
id (str): ID of the item to update
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
update_data = {
|
||||
'slots_booked': slots_used,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
|
||||
result = items.update_one(
|
||||
{'_id': ObjectId(id)},
|
||||
{'$set': update_data}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error updating item: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_slot(id, date_start_time, name):
|
||||
"""
|
||||
Remove a booked slot from an appointment's `slots_booked`.
|
||||
|
||||
Args:
|
||||
id (str): Appointment ID
|
||||
date_start_time: The start time value used when booking
|
||||
name (str): Name associated with the booking
|
||||
|
||||
Returns:
|
||||
bool: True if a slot was removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
# Attempt to pull the exact element (stored as an array/tuple)
|
||||
result = items.update_one(
|
||||
{'_id': ObjectId(id)},
|
||||
{'$pull': {'slots_booked': [date_start_time, name]}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove(id):
|
||||
"""
|
||||
Soft-delete an appointment by setting its `Deleted` flag.
|
||||
|
||||
Args:
|
||||
id (str): Appointment ID
|
||||
|
||||
Returns:
|
||||
bool: True if the appointment was marked deleted, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
result = items.delete_one({'_id': ObjectId(id)})
|
||||
|
||||
client.close()
|
||||
return result.deleted_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
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 []
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
|
||||
'''
|
||||
import hashlib
|
||||
import copy
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
@@ -59,6 +60,35 @@ def _get_tenant_db(client):
|
||||
return client[cfg.MONGODB_DB]
|
||||
|
||||
|
||||
def _has_tenant_configs():
|
||||
for module_name in ('tenant', 'Web.tenant'):
|
||||
try:
|
||||
tenant_module = importlib.import_module(module_name)
|
||||
tenant_registry = getattr(tenant_module, 'TENANT_REGISTRY', None)
|
||||
if isinstance(tenant_registry, dict) and tenant_registry:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict) and bool(cfg.TENANT_CONFIGS)
|
||||
|
||||
|
||||
def _resolve_request_tenant_db():
|
||||
for module_name in ('tenant', 'Web.tenant'):
|
||||
try:
|
||||
tenant_module = importlib.import_module(module_name)
|
||||
get_tenant_context = getattr(tenant_module, 'get_tenant_context', None)
|
||||
if not callable(get_tenant_context):
|
||||
continue
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.tenant_id:
|
||||
return ctx.db_name or ctx.resolve_tenant(), ctx.tenant_id
|
||||
if ctx and ctx.db_name and not _has_tenant_configs():
|
||||
return ctx.db_name, None
|
||||
except Exception as exc:
|
||||
logger.debug("Tenant context import %s failed: %s", module_name, exc)
|
||||
return None, None
|
||||
|
||||
|
||||
def build_name_synonym(first_name, last_name=''):
|
||||
"""Build a deterministic, non-personalized short alias from 2 letters each."""
|
||||
first = _clean_name_fragment(first_name)
|
||||
@@ -402,7 +432,7 @@ def check_password_strength(password):
|
||||
|
||||
def hashing(password):
|
||||
"""
|
||||
Hash a password using SHA-512.
|
||||
Hash a password using scrypt.
|
||||
|
||||
Args:
|
||||
password (str): Password to hash
|
||||
@@ -410,7 +440,8 @@ def hashing(password):
|
||||
Returns:
|
||||
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):
|
||||
@@ -424,22 +455,26 @@ def check_nm_pwd(username, password):
|
||||
Returns:
|
||||
dict: User document if credentials are valid, None otherwise
|
||||
"""
|
||||
db_name = cfg.MONGODB_DB
|
||||
tenant_db = None
|
||||
db_name, tenant_id = _resolve_request_tenant_db()
|
||||
ctx = None
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.tenant_id:
|
||||
tenant_db = ctx.db_name or ctx.resolve_tenant()
|
||||
db_name = tenant_db
|
||||
except Exception as exc:
|
||||
logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}")
|
||||
except Exception:
|
||||
ctx = None
|
||||
|
||||
if not db_name:
|
||||
if _has_tenant_configs():
|
||||
logger.warning(
|
||||
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
|
||||
)
|
||||
return None
|
||||
db_name = cfg.MONGODB_DB
|
||||
|
||||
logger.info(
|
||||
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
|
||||
username,
|
||||
ctx.tenant_id if ctx else None,
|
||||
tenant_id,
|
||||
db_name,
|
||||
cfg.MONGODB_HOST,
|
||||
cfg.MONGODB_PORT,
|
||||
@@ -644,15 +679,17 @@ def get_user(username):
|
||||
return users.find_one({'Username': username}) or users.find_one({'username': username})
|
||||
|
||||
# Try current tenant first when available
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.db_name:
|
||||
user = find_in_db(ctx.db_name)
|
||||
if user:
|
||||
return user
|
||||
except Exception:
|
||||
pass
|
||||
tenant_db, tenant_id = _resolve_request_tenant_db()
|
||||
if tenant_db:
|
||||
user = find_in_db(tenant_db)
|
||||
if user:
|
||||
return user
|
||||
|
||||
if _has_tenant_configs() and tenant_db is None:
|
||||
logger.warning(
|
||||
"Refusing default DB fallback for user lookup because tenant configs exist and no tenant was resolved."
|
||||
)
|
||||
return None
|
||||
|
||||
# Fallback to default configured database
|
||||
user = find_in_db(cfg.MONGODB_DB)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from modules.module_registry import ModuleRegistry as mr
|
||||
import smtplib
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
|
||||
def _build_smtp_client():
|
||||
smtp = smtplib.SMTP(cfg.EMAIL_SMTP_HOST, cfg.EMAIL_SMTP_PORT, timeout=cfg.EMAIL_TIMEOUT_SECONDS)
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USE_TLS:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USERNAME:
|
||||
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or '')
|
||||
return smtp
|
||||
|
||||
def send(email: list, subject: str, note: str, sender: str) -> bool:
|
||||
"""
|
||||
Sends the email with the link to the Clients
|
||||
|
||||
Input:
|
||||
- email: Email list of all the addresses to send the link to ["","",""]
|
||||
- subject: Subject of the email
|
||||
- note: Note that is send with the Emails
|
||||
|
||||
Output:
|
||||
- bool: true if the sending worked and false if it didnt
|
||||
"""
|
||||
if not mr.registry.is_enabled('mail'):
|
||||
return False
|
||||
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:
|
||||
smtp = _build_smtp_client()
|
||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
if smtp:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1 +1 @@
|
||||
print("hello")
|
||||
# Web.modules package initialization
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Class for all funktions of the executive -> Lehrer
|
||||
"""
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
from flask import url_for, has_request_context, request
|
||||
import Web.modules.emailservice.email as mail_service
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.settings as cfg
|
||||
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):
|
||||
if isinstance(time_span, list):
|
||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||
if isinstance(time_span, tuple):
|
||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||
if isinstance(time_span, str):
|
||||
normalized = []
|
||||
for line in time_span.replace(';', '\n').replace(',', '\n').splitlines():
|
||||
value = line.strip()
|
||||
if value:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_mail_list(mail):
|
||||
if isinstance(mail, list):
|
||||
return [str(entry).strip() for entry in mail if str(entry).strip()]
|
||||
if isinstance(mail, tuple):
|
||||
return [str(entry).strip() for entry in mail if str(entry).strip()]
|
||||
if isinstance(mail, str):
|
||||
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
|
||||
return []
|
||||
|
||||
def _escape_ics_text(value):
|
||||
return str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,').replace('\r\n', '\n').replace('\n', '\\n').replace('\r', '')
|
||||
|
||||
|
||||
def _format_ics_date(date_value):
|
||||
if isinstance(date_value, datetime.datetime):
|
||||
return date_value.strftime('%Y%m%dT%H%M%SZ')
|
||||
if isinstance(date_value, datetime.date):
|
||||
return date_value.strftime('%Y%m%d')
|
||||
return ''
|
||||
|
||||
|
||||
def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
item = termin.get_item(appointment_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
date_start = item.get('date_start')
|
||||
date_end = item.get('date_end')
|
||||
time_span = item.get('time_span', []) or []
|
||||
creator = item.get('user', 'Terminplaner')
|
||||
note = item.get('note', '') or ''
|
||||
titel = item.get('title', '') or ''
|
||||
tenant_id = _current_tenant_id()
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
link = host + "/terminplaner/client/" + str(appointment_id)
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
|
||||
end_date = datetime.datetime.strptime(str(date_end), '%Y-%m-%d').date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
uid = f"terminplaner-{appointment_id}@invario.eu"
|
||||
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
||||
summary = f"Terminplan für {creator}"
|
||||
description_lines = [
|
||||
f"Buchungslink: {link}",
|
||||
f"Zeitraum: {date_start} bis {date_end}",
|
||||
]
|
||||
if time_span:
|
||||
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
||||
if note:
|
||||
description_lines.append('Notiz: ' + str(note))
|
||||
if titel:
|
||||
description_lines.append('Titel: ' + str(titel))
|
||||
|
||||
ics_lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Inventarsystem//Terminplaner//DE',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
f'DTSTAMP:{created_at}',
|
||||
f'SUMMARY:{_escape_ics_text(summary)}',
|
||||
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
||||
f'URL:{_escape_ics_text(link)}',
|
||||
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
||||
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
|
||||
f'Titel:{_escape_ics_text(titel)}',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
]
|
||||
return '\r\n'.join(ics_lines)
|
||||
|
||||
|
||||
def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str = '') -> str | None:
|
||||
"""Build a single-slot ICS export for a client booking candidate."""
|
||||
item = termin.get_item(appointment_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
try:
|
||||
start_dt = datetime.datetime.strptime(str(slot_start).strip(), '%Y-%m-%d %H:%M')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
slot_minutes = int(item.get('slot_length') or item.get('slot_lenght') or 0)
|
||||
except Exception:
|
||||
slot_minutes = 0
|
||||
|
||||
if slot_minutes <= 0:
|
||||
slot_minutes = 45
|
||||
|
||||
end_dt = start_dt + datetime.timedelta(minutes=slot_minutes)
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
link = host + '/terminplaner/client/' + str(appointment_id)
|
||||
if tenant_id:
|
||||
link += f'?tenant={tenant_id}'
|
||||
|
||||
title_name = item.get('title') or f"Terminbuchung mit {client_name}" if client_name else "Terminbuchung"
|
||||
summary = f"{title_name} - Terminbuchung"
|
||||
description_lines = [
|
||||
f"Buchungslink: {link}",
|
||||
f"Geplanter Termin: {start_dt.strftime('%d.%m.%Y %H:%M')} - {end_dt.strftime('%H:%M')}",
|
||||
]
|
||||
|
||||
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
|
||||
created_at = datetime.datetime.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
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
|
||||
subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
|
||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||
calendar_link = None
|
||||
|
||||
if calendar_enabled:
|
||||
try:
|
||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
|
||||
if tenant_id:
|
||||
calendar_link += f"?tenant={tenant_id}"
|
||||
|
||||
email_body = note_link
|
||||
if calendar_link:
|
||||
email_body += f"\n\nKalendereintrag: {calendar_link}"
|
||||
|
||||
if normalized_mail and cfg.EMAIL_ENABLED:
|
||||
mail_service.send(normalized_mail, subject, email_body)
|
||||
|
||||
return {
|
||||
'appointment_id': id_str,
|
||||
'link': link,
|
||||
'calendar_link': calendar_link,
|
||||
}
|
||||
|
||||
|
||||
def book_slot(id, date_start_time, name):
|
||||
try:
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
slots = item.get('slots_booked', []) or []
|
||||
if not isinstance(slots, list):
|
||||
slots = []
|
||||
|
||||
capacity = int(item.get('slots', 0) or 0)
|
||||
if capacity and len(slots) >= capacity:
|
||||
return False
|
||||
|
||||
for existing in slots:
|
||||
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
||||
if existing[0] == date_start_time and existing[1] == name:
|
||||
return False
|
||||
|
||||
slots.append((date_start_time, name))
|
||||
success = termin.update(id, slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
print(f"Error booking slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_slot(id, date_start_time, name):
|
||||
try:
|
||||
if hasattr(termin, 'remove_slot'):
|
||||
removed = termin.remove_slot(id, date_start_time, name)
|
||||
if removed:
|
||||
return True
|
||||
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
slots = item.get('slots_booked', []) or []
|
||||
new_slots = []
|
||||
for s in slots:
|
||||
try:
|
||||
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
new_slots.append(s)
|
||||
|
||||
success = termin.update(id, new_slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
print(f"Error removing slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_appointment(id):
|
||||
try:
|
||||
return bool(termin.remove(id))
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
def get_available(id):
|
||||
try:
|
||||
termin_range = termin.get_item(id)
|
||||
if not termin_range:
|
||||
return {}
|
||||
|
||||
date_start = termin_range.get('date_start')
|
||||
date_end = termin_range.get('date_end')
|
||||
time_span = termin_range.get('time_span', [])
|
||||
|
||||
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 []
|
||||
|
||||
normalized = []
|
||||
for s in booked:
|
||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||
normalized.append({'start': s[0], 'name': s[1]})
|
||||
elif isinstance(s, dict):
|
||||
normalized.append(s)
|
||||
else:
|
||||
normalized.append({'value': s})
|
||||
|
||||
slots_used = len(normalized)
|
||||
try:
|
||||
slots_left = max(0, int(total_slots) - slots_used)
|
||||
except Exception:
|
||||
slots_left = max(0, slots_used - slots_used)
|
||||
|
||||
return {
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'time_span': time_span,
|
||||
'slot_length': slot_length,
|
||||
'slot_lenght': slot_length,
|
||||
'slots_total': total_slots,
|
||||
'slots_booked': normalized,
|
||||
'slots_left': slots_left,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting available slots: {e}")
|
||||
return {}
|
||||
|
||||
def get_available_user(id):
|
||||
return get_available(id)
|
||||
|
||||
|
||||
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
||||
user_name = str(user or '').strip()
|
||||
if not user_name:
|
||||
return []
|
||||
|
||||
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
|
||||
@@ -0,0 +1,263 @@
|
||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
|
||||
from flask import Response
|
||||
import Web.modules.terminplaner.backend_server as appointment_service
|
||||
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
|
||||
appoint_bp = Blueprint('terminplaner', __name__)
|
||||
|
||||
|
||||
def _require_module_enabled():
|
||||
if not cfg.MODULES.is_enabled('terminplan'):
|
||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||
return redirect(url_for('home'))
|
||||
return None
|
||||
|
||||
|
||||
def _appointment_not_found_response():
|
||||
return render_template(
|
||||
'terminplaner_not_found.html',
|
||||
error_code=404,
|
||||
error_message='Der Termin wurde nicht gefunden.',
|
||||
), 404
|
||||
|
||||
|
||||
def _current_tenant_id():
|
||||
try:
|
||||
from Web.tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and getattr(ctx, 'tenant_id', None):
|
||||
return str(ctx.tenant_id)
|
||||
except Exception:
|
||||
pass
|
||||
return str(session.get('tenant_id', '') or '').strip()
|
||||
|
||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||
def client(appointment_id):
|
||||
"""
|
||||
The Route for the terminplaner to work with the client
|
||||
"""
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
available = appointment_service.get_available(appointment_id)
|
||||
if not available:
|
||||
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':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
username = request.form.get('client_name')
|
||||
if not start_daytime or not username:
|
||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available_for_view,
|
||||
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):
|
||||
return redirect(
|
||||
url_for(
|
||||
'terminplaner.client_success',
|
||||
appointment_id=appointment_id,
|
||||
tenant=_current_tenant_id() or None,
|
||||
start=start_daytime,
|
||||
name=username,
|
||||
)
|
||||
)
|
||||
|
||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available_for_view,
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
)
|
||||
|
||||
|
||||
@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'])
|
||||
def configure():
|
||||
"""
|
||||
Route for authenticated persons to configure a new appointment for them
|
||||
"""
|
||||
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'))
|
||||
|
||||
if request.method == "POST":
|
||||
start = request.form.get('start_date')
|
||||
end = request.form.get('end_date')
|
||||
time = request.form.get('time_frame')
|
||||
slots_amount = request.form.get('slots_amounts')
|
||||
slot_length = request.form.get('slot_length') # Korrigiert: slot_length
|
||||
mail = request.form.get('mail', '')
|
||||
note = request.form.get('note', '')
|
||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||
title = request.form.get('title', '').strip() # Korrigiert: title statt titel
|
||||
|
||||
# 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')
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
generated_link=None,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
)
|
||||
|
||||
# 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')
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
generated_link=result['link'],
|
||||
calendar_link=result.get('calendar_link'),
|
||||
add_to_calendar=add_to_calendar,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=title,
|
||||
)
|
||||
elif request.method == "GET":
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
generated_link=None,
|
||||
calendar_link=None,
|
||||
add_to_calendar=False,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=None,
|
||||
)
|
||||
|
||||
|
||||
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
||||
def calendar_export(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
ics_content = appointment_service.build_calendar_ics(appointment_id)
|
||||
if not ics_content:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||
|
||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{title}-{appointment_id}.ics'
|
||||
return response
|
||||
|
||||
|
||||
@appoint_bp.route('/client_ics/<appointment_id>.ics', methods=['GET'])
|
||||
def client_slot_calendar_export(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
slot_start = str(request.args.get('start', '') or '').strip()
|
||||
client_name = str(request.args.get('name', '') or '').strip()
|
||||
ics_content = appointment_service.build_client_slot_ics(appointment_id, slot_start, client_name=client_name)
|
||||
if not ics_content:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||
|
||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||
return response
|
||||
|
||||
@appoint_bp.route('/')
|
||||
def main():
|
||||
guard = _require_module_enabled()
|
||||
if 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(
|
||||
'terminplaner.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
current_user=current_user,
|
||||
upcoming_events=upcoming_events,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
flask
|
||||
bson
|
||||
werkzeug
|
||||
gunicorn
|
||||
pymongo
|
||||
@@ -16,3 +15,4 @@ openpyxl
|
||||
cryptography>=42.0.0
|
||||
pywebpush
|
||||
py-vapid>=1.9.0
|
||||
beautifulsoup4
|
||||
+126
-75
@@ -129,6 +129,14 @@
|
||||
--module-brand-icon: "📚";
|
||||
}
|
||||
|
||||
html[data-module="terminplan"] {
|
||||
--module-primary-color: #0f4c5c;
|
||||
--module-primary-light: #16697a;
|
||||
--module-accent-color: #2ec4b6;
|
||||
--module-accent-light: rgba(46, 196, 182, 0.1);
|
||||
--module-brand-icon: "📅";
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--module-primary-color: #1a252f;
|
||||
--module-primary-light: #2c3e50;
|
||||
@@ -145,6 +153,12 @@
|
||||
--module-accent-color: #8e44ad;
|
||||
}
|
||||
|
||||
html[data-theme="dark"][data-module="terminplan"] {
|
||||
--module-primary-color: #082f35;
|
||||
--module-primary-light: #0f4c5c;
|
||||
--module-accent-color: #21b6a8;
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
body {
|
||||
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
|
||||
@@ -930,6 +944,29 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item.dropdown > .nav-link.dropdown-toggle,
|
||||
.user-menu-wrap > .user-menu-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item.dropdown > .dropdown-menu,
|
||||
.user-menu-wrap > .dropdown-menu {
|
||||
display: block !important;
|
||||
position: static !important;
|
||||
float: none !important;
|
||||
transform: none !important;
|
||||
inset: auto !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item.dropdown > .dropdown-menu {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.user-menu-wrap > .dropdown-menu {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-item {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 12px 16px;
|
||||
@@ -974,6 +1011,7 @@
|
||||
order: 3;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.function-search-form {
|
||||
@@ -1054,29 +1092,73 @@
|
||||
<span class="module-label">Module:</span>
|
||||
<div class="module-tabs">
|
||||
{% if inventory_module_enabled %}
|
||||
<a href="{{ url_for('home') }}"
|
||||
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
|
||||
id="inventoryModule"
|
||||
data-module="inventory">
|
||||
<a href="{{ url_for('home') }}" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}" id="inventoryModule" data-module="inventory">
|
||||
📦 Inventarsystem
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if inventory_module_enabled and library_module_enabled %}
|
||||
{% if inventory_module_enabled and terminplan_module_enabled %}
|
||||
<div class="module-separator"></div>
|
||||
{% endif %}
|
||||
{% if terminplan_module_enabled %}
|
||||
<a href="{{ url_for('terminplaner.main') }}" class="module-tab {% if CURRENT_MODULE == 'terminplan' %}active{% endif %}" id="terminplanModule" data-module="terminplan">
|
||||
📅 Terminplaner
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if (inventory_module_enabled or terminplan_module_enabled) and library_module_enabled %}
|
||||
<div class="module-separator"></div>
|
||||
{% endif %}
|
||||
{% if library_module_enabled %}
|
||||
<a href="{{ url_for('library_view') }}"
|
||||
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
|
||||
id="libraryModule"
|
||||
data-module="library">
|
||||
<a href="{{ url_for('library_view') }}" class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}" id="libraryModule" data-module="library">
|
||||
📚 Bibliothek
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'username' in session %}
|
||||
{% if CURRENT_MODULE != 'library' and inventory_module_enabled %}
|
||||
|
||||
{% if terminplan_module_enabled and CURRENT_MODULE == 'terminplan' %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="terminplanNavbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('terminplaner.main') }}" data-icon="📅">Terminplaner</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#terminplanNavContent">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="terminplanNavContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="terminplanNavList">
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link {% if current_path == url_for('terminplaner.main') %}nav-active{% endif %}" href="{{ url_for('terminplaner.main') }}" data-tutorial-tip="Die Terminplaner-Übersicht bündelt die wichtigsten Aktionen.">Übersicht</a>
|
||||
</li>
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link {% if current_path == url_for('terminplan') %}nav-active{% endif %}" href="{{ url_for('terminplan') }}" data-tutorial-tip="Hier sehen Sie den Kalender mit den bestehenden Terminen.">Kalender</a>
|
||||
</li>
|
||||
{% if current_permissions.pages.get('terminplan', True) and current_permissions.actions.get('can_insert', True) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
|
||||
{% if current_permissions.pages.get('home', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('library_view', True) and library_module_enabled %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_view') }}">Bibliothek</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% elif CURRENT_MODULE != 'library' and inventory_module_enabled %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
|
||||
@@ -1196,7 +1278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
|
||||
@@ -1213,11 +1295,6 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% 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) %}
|
||||
<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>
|
||||
@@ -1310,28 +1387,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
|
||||
<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>
|
||||
<!-- No navbar for anonymous users on public pages. -->
|
||||
{% endif %}
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
@@ -1345,6 +1401,11 @@
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% if 'username' not in session %}
|
||||
<div class="guest-impressum-footer">
|
||||
<a href="{{ url_for('impressum') }}">Impressum</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Cookie consent banner -->
|
||||
<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); }
|
||||
@@ -1478,6 +1539,29 @@
|
||||
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 {
|
||||
display: block;
|
||||
}
|
||||
@@ -2055,42 +2139,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile menu is collapsed: avoid hiding links preemptively.
|
||||
if (navCollapse && !navCollapse.classList.contains('show')) {
|
||||
restoreAllNavItems();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile burger menu should show the full list without overflow pruning.
|
||||
restoreAllNavItems();
|
||||
|
||||
const hiddenSources = [];
|
||||
let candidates = collectTopLevelNavSources();
|
||||
const overflowBuffer = 12;
|
||||
|
||||
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||
const toHide = pickNextNavItemToHide(candidates);
|
||||
if (!toHide) {
|
||||
break;
|
||||
}
|
||||
toHide.style.display = 'none';
|
||||
hiddenSources.unshift(toHide);
|
||||
candidates = collectTopLevelNavSources();
|
||||
}
|
||||
|
||||
rebuildOverflowControl(hiddenSources);
|
||||
|
||||
// One final pass in case the overflow menu label/count itself changed row width.
|
||||
candidates = collectTopLevelNavSources();
|
||||
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||
const toHide = pickNextNavItemToHide(candidates);
|
||||
if (!toHide) {
|
||||
break;
|
||||
}
|
||||
toHide.style.display = 'none';
|
||||
hiddenSources.unshift(toHide);
|
||||
rebuildOverflowControl(hiddenSources);
|
||||
candidates = collectTopLevelNavSources();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let resizeTimer = null;
|
||||
|
||||
+78
-50
@@ -10,24 +10,6 @@
|
||||
<h1>📚 Bibliothek</h1>
|
||||
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
||||
</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>
|
||||
|
||||
<!-- Search & Filter Section -->
|
||||
@@ -72,6 +54,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Table Header (only in table mode) -->
|
||||
<div class="library-table-wrapper">
|
||||
<div class="table-header" id="tableHeader" style="display: none;">
|
||||
<div class="table-row">
|
||||
<div class="table-cell title-cell">Titel</div>
|
||||
@@ -166,6 +149,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
@@ -203,8 +187,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
// View mode persistence
|
||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||
@@ -623,50 +606,80 @@ document.getElementById('clearFiltersBtn').addEventListener('click', function()
|
||||
closeAllFilters();
|
||||
});
|
||||
|
||||
// Favorites toggle
|
||||
document.getElementById('favoriteToggle').addEventListener('click', function() {
|
||||
this.classList.toggle('open');
|
||||
// TODO: Implement favorites filtering
|
||||
});
|
||||
|
||||
// Scanner toggle
|
||||
let scanner = null;
|
||||
// Scanner state tracking
|
||||
let isScanning = false;
|
||||
|
||||
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const container = document.getElementById('qrContainer');
|
||||
const isOpen = container.style.display !== 'none';
|
||||
const btn = this;
|
||||
|
||||
if (isOpen) {
|
||||
// Close the scanner
|
||||
container.style.display = 'none';
|
||||
if (scanner) scanner.clear();
|
||||
this.classList.remove('open');
|
||||
this.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
this.classList.add('open');
|
||||
this.setAttribute('aria-expanded', 'true');
|
||||
Quagga.stop();
|
||||
isScanning = false;
|
||||
|
||||
if (!scanner) {
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanner.start(
|
||||
{ facingMode: "environment" },
|
||||
{ fps: 10, qrbox: 250 },
|
||||
onScanSuccess,
|
||||
onScanError
|
||||
);
|
||||
}
|
||||
btn.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
// Open the scanner
|
||||
container.style.display = 'block';
|
||||
btn.classList.add('open');
|
||||
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) {
|
||||
const studentId = decodedText.trim();
|
||||
// Single Quagga event listener for successful scans
|
||||
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;
|
||||
alert('Ausweis gescannt: ' + studentId);
|
||||
}
|
||||
|
||||
function onScanError(error) {
|
||||
// Silently ignore scan errors
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -720,5 +733,20 @@ function onScanError(error) {
|
||||
background: #fce8e6;
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
/* Mobile: allow horizontal scrolling for table-mode views */
|
||||
@media (max-width: 900px) {
|
||||
.library-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: 100%;
|
||||
}
|
||||
.library-table-wrapper .table-row {
|
||||
min-width: 720px; /* allow table to have intrinsic width and be scrolled */
|
||||
}
|
||||
.table-header, .item-content.table-mode {
|
||||
display: block; /* keep rows stacked but allow horizontal scroll */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
+461
-412
File diff suppressed because it is too large
Load Diff
+8
-164
@@ -1,11 +1,3 @@
|
||||
<!--
|
||||
Copyright 2025-2026 AIIrondev
|
||||
|
||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||
See Legal/LICENSE for the full license text.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
-->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}License - Inventarsystem{% endblock %}
|
||||
@@ -17,10 +9,6 @@
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<ul class="nav nav-tabs mt-3" id="legalTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-eula" data-bs-toggle="tab" data-bs-target="#pane-eula"
|
||||
type="button" role="tab">EULA</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-notice" data-bs-toggle="tab" data-bs-target="#pane-notice"
|
||||
type="button" role="tab">NOTICE</button>
|
||||
@@ -40,142 +28,14 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
|
||||
type="button" role="tab">Rechtsgrundlage</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="legalTabContent">
|
||||
|
||||
<!-- ── EULA ─────────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade show active" id="pane-eula" role="tabpanel">
|
||||
<div class="license-content">
|
||||
|
||||
<!-- EULA -->
|
||||
<h2>Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen</h2>
|
||||
<p><strong>Softwareprojekt:</strong> Inventarsystem<br>
|
||||
<strong>Urheberrechtshalter (Lizenzgeber):</strong> AIIrondev<br>
|
||||
<strong>Gültigkeit:</strong> Stand 2026</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3>Präambel</h3>
|
||||
<p>Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag") stellt eine rechtsgültige Vereinbarung zwischen Ihnen
|
||||
(im Folgenden „Lizenznehmer") und dem Urheber <strong>AIIrondev</strong> (im Folgenden „Lizenzgeber") dar.
|
||||
Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software
|
||||
„Inventarsystem" (im Folgenden „Produkt") erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen
|
||||
vollumfänglich einverstanden.</p>
|
||||
<p>Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung,
|
||||
Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3>§ 1 Gegenstand der Lizenz und Eigentumsrechte</h3>
|
||||
<ol>
|
||||
<li>Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche
|
||||
Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen
|
||||
Kopien davon verbleiben ausschließlich beim Lizenzgeber.</li>
|
||||
<li>Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag
|
||||
explizit genannten Bedingungen.</li>
|
||||
</ol>
|
||||
|
||||
<h3>§ 2 Zulässiger Nutzungskreis (Privatnutzung)</h3>
|
||||
<ol>
|
||||
<li>Die unentgeltliche Nutzung des Produkts ist ausschließlich <strong>natürlichen Personen für den
|
||||
rein privaten, häuslichen Gebrauch</strong> gestattet.</li>
|
||||
<li>Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.</li>
|
||||
<li>Jegliche Nutzung durch <strong>Institutionelle Nutzer</strong> (einschließlich, aber nicht
|
||||
beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen,
|
||||
Behörden oder NGOs) ist ausdrücklich <strong>untersagt</strong> und bedarf einer gesonderten,
|
||||
schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.</li>
|
||||
</ol>
|
||||
|
||||
<h3>§ 3 Funktionale Einschränkungen und Support</h3>
|
||||
<ol>
|
||||
<li>Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is").</li>
|
||||
<li>Für die kostenlose Privatnutzung besteht <strong>kein Anspruch</strong> auf:
|
||||
<ul>
|
||||
<li>Technischen Support oder Beratung.</li>
|
||||
<li>Bereitstellung von Sicherheits-Updates oder Patches.</li>
|
||||
<li>Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern,
|
||||
einzuschränken oder kostenpflichtig zu gestalten.</li>
|
||||
</ol>
|
||||
|
||||
<h3>§ 4 Wahrung der Urheberbezeichnung (Branding-Klausel)</h3>
|
||||
<ol>
|
||||
<li>Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung
|
||||
<strong>„Powered by AIIrondev"</strong> in der Benutzeroberfläche (Footer/Menü).</li>
|
||||
<li>Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder
|
||||
deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder
|
||||
Metadaten) zu beeinträchtigen.</li>
|
||||
<li>Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der
|
||||
Nutzungslizenz.</li>
|
||||
</ol>
|
||||
|
||||
<h3>§ 5 Verbot der kommerziellen Verwertung & SaaS</h3>
|
||||
<ol>
|
||||
<li>Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS),
|
||||
insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt.</li>
|
||||
<li>Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf
|
||||
die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des
|
||||
Lizenzgebers zulässig.</li>
|
||||
</ol>
|
||||
|
||||
<h3>§ 6 Modifikationen und Contributions</h3>
|
||||
<ol>
|
||||
<li>Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.</li>
|
||||
<li>Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von
|
||||
Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber
|
||||
ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und
|
||||
Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in
|
||||
das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben.</li>
|
||||
</ol>
|
||||
|
||||
<h3>§ 7 Haftungsbeschränkung</h3>
|
||||
<ol>
|
||||
<li>Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des
|
||||
Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn),
|
||||
ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.</li>
|
||||
<li>Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten
|
||||
Daten.</li>
|
||||
</ol>
|
||||
|
||||
<h3>§ 8 Rechtswahl und Gerichtsstand</h3>
|
||||
<ol>
|
||||
<li>Es gilt ausschließlich das Recht der <strong>Bundesrepublik Deutschland</strong> unter Ausschluss
|
||||
des UN-Kaufrechts (CISG).</li>
|
||||
<li>Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der
|
||||
Sitz des Lizenzgebers vereinbart.</li>
|
||||
<li>Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen
|
||||
Bestimmungen unberührt (Salvatorische Klausel).</li>
|
||||
</ol>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="license-exception-notice">
|
||||
<h3>⚠️ Anfragen für Ausnahmegenehmigungen (Kommerzielle Lizenzen)</h3>
|
||||
<p>
|
||||
Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil:
|
||||
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">AIIrondev auf GitHub</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /.license-content -->
|
||||
</div><!-- /#pane-eula -->
|
||||
|
||||
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-notice" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>NOTICE – Urheberrechtliche Hinweise</h2>
|
||||
<p><strong>Inventarsystem</strong><br>Copyright © 2025-2026 AIIrondev</p>
|
||||
<p>Dieses Projekt steht unter dem Inventarsystem EULA (Endbenutzer-Lizenzvertrag). Der vollständige Lizenztext
|
||||
befindet sich in <code>Legal/LICENSE</code>. Die Software wird ausschließlich für den privaten,
|
||||
nicht-kommerziellen Gebrauch bereitgestellt. Unerlaubte kommerzielle Nutzung, SaaS-Hosting oder das Entfernen
|
||||
von Branding ist untersagt. Anfragen für kommerzielle Lizenzen:
|
||||
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">https://github.com/AIIrondev</a></p>
|
||||
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
|
||||
|
||||
<hr>
|
||||
<h3>Drittanbieter-Hinweise</h3>
|
||||
<p>Dieses Projekt nutzt oder referenziert die folgende Drittanbieter-Software. Deren jeweilige Lizenzen gelten
|
||||
wie angegeben. Dieser NOTICE-Abschnitt dient ausschließlich der Attribution und ändert keine Lizenzbedingungen.</p>
|
||||
@@ -198,21 +58,14 @@
|
||||
|
||||
<h4>Frontend / Laufzeit-Assets</h4>
|
||||
<ul>
|
||||
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
|
||||
<li>quagga2 (MIT) © 2014 Christoph Oberhofer, © 2019 Eric Blade and contributors</li>
|
||||
</ul>
|
||||
|
||||
<h4>Systemkomponenten (nicht im Repository enthalten, ggf. im Deployment genutzt)</h4>
|
||||
<ul>
|
||||
<li>NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors</li>
|
||||
<li>FFmpeg (LGPL/GPL, je nach Konfiguration) © 2000-2025 FFmpeg developers</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
</div>
|
||||
</div><!-- /#pane-notice -->
|
||||
|
||||
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-privacy" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-privacy" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Datenschutzerklärung (Privacy Policy)</h2>
|
||||
|
||||
@@ -254,13 +107,10 @@
|
||||
</div><!-- /#pane-privacy -->
|
||||
|
||||
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-security" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-security" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
|
||||
|
||||
<h3>Unterstützte Versionen</h3>
|
||||
<p>Bitte nutzen Sie immer die neueste Version aus dem <code>main</code>-Branch, um Sicherheitsupdates zu erhalten.</p>
|
||||
|
||||
<h3>Datenschutzrechtliche Mechanismen</h3>
|
||||
<ul>
|
||||
<li><strong>Passwort-Hashing:</strong> Passwörter werden niemals im Klartext gespeichert.</li>
|
||||
@@ -272,16 +122,13 @@
|
||||
<h3>Meldung von Sicherheitslücken</h3>
|
||||
<div class="license-exception-notice">
|
||||
<h3>⚠️ Responsible Disclosure</h3>
|
||||
<p>Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte <strong>kein öffentliches Issue</strong>.
|
||||
Kontaktieren Sie den Maintainer direkt über die
|
||||
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">GitHub Security Advisory-Funktion</a>
|
||||
oder per E-Mail.</p>
|
||||
<p>Falls Sie eine Sicherheitslücke entdecken, wenden sie sich umgehend an die Email: info@invario.eu .
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /#pane-security -->
|
||||
|
||||
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-data" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-data" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
|
||||
|
||||
@@ -309,7 +156,7 @@
|
||||
</div><!-- /#pane-data -->
|
||||
|
||||
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-legal" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-legal" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Rechtsgrundlage der Nutzung</h2>
|
||||
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
|
||||
@@ -322,9 +169,6 @@
|
||||
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div><!-- /#pane-legal -->
|
||||
|
||||
</div><!-- /.tab-content -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+107
-172
@@ -269,23 +269,6 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Compact appointment badge on cards */
|
||||
.appointment-badge {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
background-color: #e1f5fe;
|
||||
border-left: 4px solid #03a9f4;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.appointment-badge .appointment-time {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
color: #01579b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Search styles */
|
||||
.search-container {
|
||||
display: flex;
|
||||
@@ -390,7 +373,7 @@
|
||||
|
||||
<div class="filter-group">
|
||||
<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="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||
</div>
|
||||
@@ -437,7 +420,7 @@
|
||||
<span>Ort</span>
|
||||
<span>Unterrichtsfach</span>
|
||||
<span>Jahrgangsstufe</span>
|
||||
<span>Thema</span>
|
||||
<span>Schlagwort</span>
|
||||
<span>Barcode</span>
|
||||
</div>
|
||||
<div id="items-container" class="items-container">
|
||||
@@ -542,8 +525,7 @@
|
||||
|
||||
window.isDebug = false; // Set to true only for development environment
|
||||
</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>
|
||||
// Global state
|
||||
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
||||
@@ -552,51 +534,114 @@
|
||||
let codeSearchTerm = '';
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
|
||||
// QR scanner toggle setup
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const scanButton = document.getElementById('scanButton');
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let isScanning = false;
|
||||
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||
|
||||
function startScanner(targetCallback) {
|
||||
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) => {
|
||||
scanButton.classList.toggle('is-active', isOpen);
|
||||
scanButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
scanButton.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||
};
|
||||
// Show the container
|
||||
if (qrReader) qrReader.style.display = 'block';
|
||||
if (statusText) statusText.innerText = "Initializing camera...";
|
||||
|
||||
scanButton.addEventListener('click', function() {
|
||||
if (qrReader.style.display !== 'block') {
|
||||
qrReader.style.display = 'block';
|
||||
|
||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
||||
'qr-reader', { fps: 10, qrbox: 250 }
|
||||
);
|
||||
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
|
||||
// Put scanned code into the search box and trigger search
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = decodedText;
|
||||
searchByCode();
|
||||
}
|
||||
|
||||
setScannerUi(false);
|
||||
});
|
||||
|
||||
setScannerUi(true);
|
||||
} else {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
setScannerUi(false);
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Make sure this targets your correct HTML container element
|
||||
target: document.querySelector('#scanner-container'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear camera
|
||||
},
|
||||
},
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -1006,30 +1051,13 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Add appointment badge if item has upcoming appointments
|
||||
let appointmentBadge = '';
|
||||
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
|
||||
const upcomingAppointment = getUpcomingAppointment(item.appointments);
|
||||
if (upcomingAppointment) {
|
||||
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
|
||||
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
|
||||
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
|
||||
|
||||
appointmentBadge = `
|
||||
<div class="appointment-badge">
|
||||
<strong>Geplant für:</strong> ${formattedDate}<br>
|
||||
<span class="appointment-time">${startPeriod}${endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : ''}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-content" data-item-id="${item._id}">
|
||||
<h3 class="item-col-name">${item.Name}</h3>
|
||||
<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-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-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||
@@ -1037,7 +1065,6 @@
|
||||
${imagesHtml}
|
||||
</div>
|
||||
${borrowerBadge}
|
||||
${appointmentBadge}
|
||||
</div>
|
||||
<div class="actions">
|
||||
${isAvailableForBorrow && !item.BlockedNow ?
|
||||
@@ -1614,27 +1641,6 @@
|
||||
`;
|
||||
}
|
||||
|
||||
// Add appointment info panel if item has appointments
|
||||
let appointmentInfoHtml = '';
|
||||
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
|
||||
const upcomingAppointment = getUpcomingAppointment(item.appointments);
|
||||
if (upcomingAppointment) {
|
||||
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
|
||||
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
|
||||
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
|
||||
const timeDisplay = startPeriod + (endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : '');
|
||||
|
||||
appointmentInfoHtml = `
|
||||
<div class="appointment-info-panel">
|
||||
<h4>Geplanter Termin</h4>
|
||||
<p class="appointment-date">Datum: ${formattedDate}</p>
|
||||
<p class="appointment-time">Zeit: ${timeDisplay}</p>
|
||||
${upcomingAppointment.notes ? `<p class="appointment-notes">Notizen: ${upcomingAppointment.notes}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build modal content HTML
|
||||
modalContent.innerHTML = `
|
||||
<h2>${item.Name}</h2>
|
||||
@@ -1642,7 +1648,6 @@
|
||||
<button type="button" class="bookmark-btn" id="modal-bookmark-btn" title="Merken">${(window.currentFavorites||new Set()).has(item._id) ? '★' : '☆'}</button>
|
||||
</div>
|
||||
${borrowerInfoHtml}
|
||||
${appointmentInfoHtml}
|
||||
|
||||
<div class="modal-image-container">
|
||||
${imagesHtml}
|
||||
@@ -1677,7 +1682,7 @@
|
||||
</div>
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Thema:</div>
|
||||
<div class="detail-label">Schlagwort:</div>
|
||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||
</div>
|
||||
|
||||
@@ -2804,66 +2809,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for appointment display
|
||||
function formatAppointmentDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAppointmentPeriod(period) {
|
||||
if (!period) return '';
|
||||
const periodMap = {
|
||||
'1': '1. Stunde',
|
||||
'2': '2. Stunde',
|
||||
'3': '3. Stunde',
|
||||
'4': '4. Stunde',
|
||||
'5': '5. Stunde',
|
||||
'6': '6. Stunde',
|
||||
'7': '7. Stunde',
|
||||
'8': '8. Stunde',
|
||||
'9': '9. Stunde',
|
||||
'10': '10. Stunde',
|
||||
'pause1': '1. Pause',
|
||||
'pause2': '2. Pause',
|
||||
'mittagspause': 'Mittagspause'
|
||||
};
|
||||
return periodMap[period] || period;
|
||||
}
|
||||
|
||||
function getUpcomingAppointment(appointments) {
|
||||
if (!appointments || !Array.isArray(appointments) || appointments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = now.toDateString();
|
||||
|
||||
// Filter appointments that are today or in the future
|
||||
const futureAppointments = appointments.filter(apt => {
|
||||
try {
|
||||
const aptDate = new Date(apt.date);
|
||||
return aptDate.toDateString() === today || aptDate >= now.setHours(0, 0, 0, 0);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (futureAppointments.length === 0) return null;
|
||||
|
||||
// Sort by date and return the earliest
|
||||
futureAppointments.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
return futureAppointments[0];
|
||||
}
|
||||
|
||||
function formatDateForInput(dateString) {
|
||||
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
|
||||
const date = new Date(dateString);
|
||||
@@ -3947,16 +3892,6 @@
|
||||
.borrower-info-panel h4 { color: #856404; margin: 0 0 10px 0; font-size: 1.1rem; }
|
||||
.borrower-name, .borrow-time { margin: 5px 0; color: #856404; font-weight: 500; }
|
||||
|
||||
.appointment-info-panel {
|
||||
background-color: #e1f5fe;
|
||||
border: 1px solid #81d4fa;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.appointment-info-panel h4 { color: #0277bd; margin: 0 0 10px 0; font-size: 1.1rem; }
|
||||
.appointment-date, .appointment-time, .appointment-user { margin: 5px 0; color: #0277bd; font-weight: 500; }
|
||||
|
||||
/* Modal Details Styling (text formatting) */
|
||||
.modal-details { margin: 20px 0; }
|
||||
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
|
||||
|
||||
+129
-80
@@ -2395,7 +2395,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
<label>Thema:</label>
|
||||
<label>Schlagwort:</label>
|
||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||
</div>
|
||||
@@ -2435,7 +2435,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
<div class="qr-container">
|
||||
<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 id="table-view-header" class="table-view-header" aria-hidden="true">
|
||||
@@ -2443,7 +2446,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<span>Ort</span>
|
||||
<span>Unterrichtsfach</span>
|
||||
<span>Jahrgangsstufe</span>
|
||||
<span>Thema</span>
|
||||
<span>Schlagwort</span>
|
||||
<span>Barcode</span>
|
||||
<span>Anzahl</span>
|
||||
</div>
|
||||
@@ -2564,7 +2567,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Thema:</h3>
|
||||
<h3>Schlagwort:</h3>
|
||||
<div class="multi-filter">
|
||||
<div class="form-group">
|
||||
<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
|
||||
</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>
|
||||
// Function to check if a file is a video
|
||||
function isVideoFile(filename) {
|
||||
@@ -2739,7 +2742,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
|
||||
// Initialize QR Code scanner and global variables
|
||||
let html5QrcodeScanner = null;
|
||||
let codeSearchTerm = '';
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
let currentUsername = '';
|
||||
@@ -2768,51 +2770,114 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
// No-op in production
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('scanButton').addEventListener('click', function() {
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
const scanBtn = document.getElementById('scanButton');
|
||||
|
||||
const setScannerUi = (isOpen) => {
|
||||
if (!scanBtn) return;
|
||||
scanBtn.classList.toggle('is-active', isOpen);
|
||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||
};
|
||||
|
||||
if (qrReader.style.display === 'none') {
|
||||
qrReader.style.display = 'block';
|
||||
|
||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
||||
"qr-reader", {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
}
|
||||
);
|
||||
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
|
||||
// Instead of navigating to the URL, put the scanned code in the search box
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = decodedText;
|
||||
// Trigger search automatically
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let isScanning = false;
|
||||
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||
|
||||
function startScanner(targetCallback) {
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
const statusText = document.getElementById('status');
|
||||
|
||||
// Store the custom action that should happen when a barcode is found
|
||||
activeScannerCallback = targetCallback;
|
||||
|
||||
// Show the container
|
||||
if (qrReader) qrReader.style.display = 'block';
|
||||
if (statusText) statusText.innerText = "Initializing camera...";
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Make sure this targets your correct HTML container element
|
||||
target: document.querySelector('#scanner-container'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear camera
|
||||
},
|
||||
},
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (qrReader.style.display !== 'none') {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
// Toggle close if it's already running
|
||||
if (isScanning && qrReader.style.display !== 'none') {
|
||||
stopScanner();
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
return;
|
||||
}
|
||||
|
||||
qrReader.style.display = 'block';
|
||||
scanEditBtn.textContent = 'Scanner schließen';
|
||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
editCodeInput.value = String(decodedText || '').trim();
|
||||
|
||||
// Start scanner with custom logic mapping to the Code input field
|
||||
startScanner(function(decodedText) {
|
||||
editCodeInput.value = decodedText;
|
||||
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
});
|
||||
@@ -2857,26 +2914,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrReader.style.display !== 'none') {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
// Toggle close if it's already running
|
||||
if (isScanning && qrReader.style.display !== 'none') {
|
||||
stopScanner();
|
||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||
return;
|
||||
}
|
||||
|
||||
qrReader.style.display = 'block';
|
||||
scanIsbnBtn.textContent = 'Scanner schließen';
|
||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
editIsbnInput.value = String(decodedText || '').trim();
|
||||
|
||||
// Start scanner with custom logic mapping to the ISBN input field
|
||||
startScanner(function(decodedText) {
|
||||
editIsbnInput.value = decodedText;
|
||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||
if (typeof fetchBookInfo === 'function') {
|
||||
fetchBookInfo('edit');
|
||||
@@ -3637,7 +3686,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<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-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-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||
${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-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] : []);
|
||||
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
||||
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
||||
@@ -4494,7 +4543,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Thema:</div>
|
||||
<div class="detail-label">Schlagwort:</div>
|
||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Wartungsarbeiten - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8 col-xl-7">
|
||||
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Systemstatus</p>
|
||||
<h1 class="h3 mb-0 fw-bold">Wartungsarbeiten</h1>
|
||||
</div>
|
||||
<div class="card-body p-4 p-md-5 bg-white text-center">
|
||||
<div class="display-3 fw-bold text-warning mb-3">500</div>
|
||||
<p class="lead mb-2">Das System führt gerade Wartungsarbeiten durch.</p>
|
||||
<p class="text-muted mb-4">Bitte versuchen Sie es in wenigen Minuten erneut. Falls das Problem bestehen bleibt, informieren Sie bitte die Administration.</p>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
|
||||
<a href="{{ url_for('login') }}" class="btn btn-primary btn-lg">Zum Login</a>
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<div class="password-rules" id="password-rules" aria-live="polite">
|
||||
<p class="password-rules-title">Passwort-Anforderungen (live):</p>
|
||||
<p class="password-rules-title">Passwort-Anforderungen:</p>
|
||||
<ul>
|
||||
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li>
|
||||
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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 %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xxl-11">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
|
||||
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
||||
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Zeitraum</div>
|
||||
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Verfügbare Slots</div>
|
||||
<div>{{ available.slots_left }} von {{ available.slots_total }} frei</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Slot-Länge</div>
|
||||
<div>{{ available.slot_lenght }} Minuten</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 %}
|
||||
<div class="p-3 rounded-3 bg-light">
|
||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||
<ul class="mb-0 small">
|
||||
{% for booking in available.slots_booked %}
|
||||
<li>
|
||||
{{ booking.start }}
|
||||
{% if can_view_booking_names and booking.name %}
|
||||
- {{ booking.name }}
|
||||
{% else %}
|
||||
- Belegt
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||
|
||||
<div class="day-slider-wrap mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
||||
<span class="small text-muted">Tag wählen</span>
|
||||
<strong id="day-slider-label" class="small"></strong>
|
||||
</div>
|
||||
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
|
||||
</div>
|
||||
|
||||
<div id="client-slot-calendar" class="mb-4"></div>
|
||||
|
||||
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
||||
<div>
|
||||
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
||||
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
|
||||
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
||||
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||
</div>
|
||||
<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>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="calendarDownloadModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Termin zum Kalender hinzufügen?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Sie haben einen Termin ausgewählt.</p>
|
||||
<p class="mb-0 small text-muted">Mit einem Klick auf ".ics herunterladen" können Sie den Termin in Apple/Google/Outlook-Kalender importieren.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button id="confirm-booking-only" type="button" class="btn btn-primary">Jetzt buchen</button>
|
||||
<button id="confirm-booking-with-ics" type="button" class="btn btn-success">Buchen + .ics</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script 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 %}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,375 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terminplan konfigurieren - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
|
||||
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
|
||||
</div>
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-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">
|
||||
<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">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
|
||||
<div class="form-text mb-0">Die Start-, End- und Pausenzeiten werden auf alle erzeugten Tage angewandt.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<label for="default_day_start" class="form-label fw-semibold">Standard-Start</label>
|
||||
<input type="time" id="default_day_start" class="form-control" value="08:00">
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
|
||||
<input type="time" id="default_day_end" class="form-control" value="16:00">
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<label for="default_pause_start" class="form-label fw-semibold text-danger">Pause von</label>
|
||||
<input type="time" id="default_pause_start" class="form-control border-danger-subtle" value="12:00">
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<label for="default_pause_end" class="form-label fw-semibold text-danger">Pause bis</label>
|
||||
<input type="time" id="default_pause_end" class="form-control border-danger-subtle" value="12:45">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="time_frame_days" class="vstack gap-2"></div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster (Übertragung ans Backend)</label>
|
||||
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" readonly></textarea>
|
||||
<div class="form-text">Pausen teilen den Tag automatisch in zwei buchbare Blöcke, sodass die Pause sicher blockiert ist.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
||||
<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>
|
||||
|
||||
{% if mail_service_enabled %}
|
||||
<div>
|
||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||
<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>
|
||||
{% endif %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
||||
<div class="form-text">Erzeugt zusätzlich eine .ics-Datei, die in gängige Kalender importiert werden kann.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Terminplan erzeugen</button>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if generated_link %}
|
||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||
<div class="mb-2">
|
||||
{% if mail_service_enabled %}
|
||||
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||
{% else %}
|
||||
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
|
||||
{% if calendar_link %}
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">Kalendereintrag</div>
|
||||
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const startDateInput = document.getElementById('start_date');
|
||||
const endDateInput = document.getElementById('end_date');
|
||||
const buildButton = document.getElementById('build_time_frame');
|
||||
const daysContainer = document.getElementById('time_frame_days');
|
||||
const timeFrameTextarea = document.getElementById('time_frame');
|
||||
|
||||
const defaultStartInput = document.getElementById('default_day_start');
|
||||
const defaultEndInput = document.getElementById('default_day_end');
|
||||
const defaultPauseStartInput = document.getElementById('default_pause_start');
|
||||
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
||||
|
||||
const slotLengthInput = document.getElementById('slot_length');
|
||||
const slotsAmountsInput = document.getElementById('slots_amounts');
|
||||
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
|
||||
|
||||
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) return null;
|
||||
const parts = value.split('-').map(Number);
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
|
||||
return new Date(parts[0], parts[1] - 1, parts[2]);
|
||||
}
|
||||
|
||||
function formatDateForRow(date) {
|
||||
return weekdayFormatter.format(date);
|
||||
}
|
||||
|
||||
function formatDateForValue(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0;
|
||||
const parts = timeStr.split(':').map(Number);
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
function updateSlotsDisplay(totalSlots) {
|
||||
if (slotsAmountsInput) slotsAmountsInput.value = totalSlots;
|
||||
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||
const lines = [];
|
||||
|
||||
rows.forEach(function (row) {
|
||||
const dayValue = row.getAttribute('data-day-value') || '';
|
||||
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
||||
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
||||
|
||||
if (!dayValue || !startTime || !endTime) return;
|
||||
|
||||
// Hat der Nutzer eine Pause eingetragen, die innerhalb des Tagesfensters liegt?
|
||||
if (pauseStart && pauseEnd && pauseStart < pauseEnd && pauseStart > startTime && pauseStart < endTime) {
|
||||
// Zeile am Pausenbeginn aufsplitten -> 2 Blöcke = automatische Pause
|
||||
lines.push(`${dayValue} ${startTime}-${pauseStart}`);
|
||||
lines.push(`${dayValue} ${pauseEnd}-${endTime}`);
|
||||
} else {
|
||||
lines.push(`${dayValue} ${startTime}-${endTime}`);
|
||||
}
|
||||
});
|
||||
|
||||
timeFrameTextarea.value = lines.join('\n');
|
||||
calculateSlots(); // Nach jedem Update neu berechnen
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const startDate = parseDate(startDateInput.value);
|
||||
const endDate = parseDate(endDateInput.value);
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
daysContainer.innerHTML = '<div class="text-muted small">Bitte Start- und Enddatum auswählen, damit die Tageszeilen erzeugt werden.</div>';
|
||||
timeFrameTextarea.value = '';
|
||||
endDateInput.setCustomValidity('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (endDate < startDate) {
|
||||
daysContainer.innerHTML = '<div class="text-danger small">Das Enddatum muss nach dem Startdatum liegen.</div>';
|
||||
timeFrameTextarea.value = '';
|
||||
endDateInput.setCustomValidity('Das Enddatum muss nach dem Startdatum liegen.');
|
||||
return;
|
||||
}
|
||||
|
||||
endDateInput.setCustomValidity('');
|
||||
|
||||
// Bisherige Werte sichern
|
||||
const existingValues = new Map();
|
||||
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
|
||||
const dayValue = row.getAttribute('data-day-value');
|
||||
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
||||
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
||||
|
||||
if (dayValue) {
|
||||
existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
|
||||
}
|
||||
});
|
||||
|
||||
const defaultStart = defaultStartInput.value || '08:00';
|
||||
const defaultEnd = defaultEndInput.value || '16:00';
|
||||
const defaultPauseStart = defaultPauseStartInput.value || '';
|
||||
const defaultPauseEnd = defaultPauseEndInput.value || '';
|
||||
const rows = [];
|
||||
|
||||
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
|
||||
const dayValue = formatDateForValue(current);
|
||||
const preserved = existingValues.get(dayValue) || {};
|
||||
|
||||
const rowStart = preserved.startTime !== undefined ? preserved.startTime : defaultStart;
|
||||
const rowEnd = preserved.endTime !== undefined ? preserved.endTime : defaultEnd;
|
||||
const rowPauseStart = preserved.pauseStart !== undefined ? preserved.pauseStart : defaultPauseStart;
|
||||
const rowPauseEnd = preserved.pauseEnd !== undefined ? preserved.pauseEnd : defaultPauseEnd;
|
||||
|
||||
rows.push(`
|
||||
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-lg-4">
|
||||
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
|
||||
<div class="text-muted small">${dayValue}</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label mb-1 text-muted small">Von</label>
|
||||
<input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label mb-1 text-muted small">Bis</label>
|
||||
<input type="time" class="form-control form-control-sm" value="${rowEnd}" data-time-end>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label mb-1 text-danger small">Pause ab</label>
|
||||
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseStart}" data-time-pause-start>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label mb-1 text-danger small">Pause bis</label>
|
||||
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseEnd}" data-time-pause-end>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
daysContainer.innerHTML = rows.join('');
|
||||
daysContainer.querySelectorAll('input[type="time"]').forEach(function (input) {
|
||||
input.addEventListener('input', syncTextarea);
|
||||
input.addEventListener('change', syncTextarea);
|
||||
});
|
||||
|
||||
syncTextarea();
|
||||
}
|
||||
|
||||
// Event Listeners Registration
|
||||
buildButton.addEventListener('click', renderRows);
|
||||
startDateInput.addEventListener('change', renderRows);
|
||||
endDateInput.addEventListener('change', renderRows);
|
||||
|
||||
defaultStartInput.addEventListener('change', renderRows);
|
||||
defaultEndInput.addEventListener('change', renderRows);
|
||||
defaultPauseStartInput.addEventListener('change', renderRows);
|
||||
defaultPauseEndInput.addEventListener('change', renderRows);
|
||||
|
||||
// Slot Berechnung auf Ändeurng der Länge antriggern
|
||||
if(slotLengthInput) {
|
||||
slotLengthInput.addEventListener('input', calculateSlots);
|
||||
slotLengthInput.addEventListener('change', calculateSlots);
|
||||
}
|
||||
|
||||
if (startDateInput.value && endDateInput.value) {
|
||||
renderRows();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+187
-809
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terminplaner - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-11 col-xl-10">
|
||||
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
||||
<div>
|
||||
<p class="text-uppercase fw-semibold mb-2" style="letter-spacing: .12em; opacity: .8;">Terminplaner</p>
|
||||
<h1 class="display-6 fw-bold mb-3">Reservierungen und Termine übersichtlich verwalten</h1>
|
||||
<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 class="d-flex flex-wrap gap-2">
|
||||
<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', tenant=tenant_id) }}">Kalender öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">🗓️</div>
|
||||
<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>
|
||||
<a class="btn btn-primary w-100" href="{{ url_for('terminplan', tenant=tenant_id) }}">Zum Kalender</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">✍️</div>
|
||||
<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>
|
||||
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Konfiguration öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">🔗</div>
|
||||
<h2 class="h4 fw-bold">Buchungslink</h2>
|
||||
<p class="mb-4 text-muted">Die öffentliche Buchungsseite liegt unter dem jeweiligen Terminlink und kann direkt an Teilnehmende weitergegeben werden.</p>
|
||||
<div class="small text-muted">Die Buchungsseite wird aus dem Terminplan automatisch erzeugt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user %}
|
||||
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||
<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>
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Termin nicht gefunden - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8 col-xl-7">
|
||||
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Terminplaner</p>
|
||||
<h1 class="h3 mb-0 fw-bold">Termin nicht gefunden</h1>
|
||||
</div>
|
||||
<div class="card-body p-4 p-md-5 bg-white text-center">
|
||||
<div class="display-3 fw-bold text-danger mb-3">404</div>
|
||||
<p class="lead mb-2">Der angeforderte Termin existiert nicht mehr oder der Link ist ungültig.</p>
|
||||
<p class="text-muted mb-4">Bitte prüfen Sie den Link erneut oder öffnen Sie den Terminplaner von der Startseite aus.</p>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
|
||||
<a href="{{ url_for('home') }}" class="btn btn-primary btn-lg">Zur Startseite</a>
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+419
-312
@@ -155,6 +155,27 @@
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -716,6 +737,8 @@
|
||||
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
|
||||
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<input type="file" name="library_excel" accept=".xlsx,.csv" required>
|
||||
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button>
|
||||
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('library')">?</button>
|
||||
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
||||
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
|
||||
</form>
|
||||
@@ -726,6 +749,8 @@
|
||||
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
|
||||
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
|
||||
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
|
||||
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('inventory')">?</button>
|
||||
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
||||
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
|
||||
</form>
|
||||
@@ -763,7 +788,19 @@
|
||||
{% if show_library_features %}
|
||||
<!-- Library Mode: Single Customizable Filter -->
|
||||
<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">
|
||||
<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>
|
||||
@@ -827,7 +864,7 @@
|
||||
<!-- Options will be loaded by JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="formupload_admin-group">
|
||||
<label for="filter2-4">Wert 4:</label>
|
||||
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
|
||||
<option value="">-- Optional --</option>
|
||||
@@ -836,7 +873,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Thema:</h3>
|
||||
<h3>Schlagwort:</h3>
|
||||
<div class="multi-filter">
|
||||
<div class="form-group">
|
||||
<label for="filter3-1">Wert 1:</label>
|
||||
@@ -865,25 +902,41 @@
|
||||
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
||||
<input id="anschaffungskosten" name="anschaffungskosten">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code_4">Code</label>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||
<input id="code_4" name="code_4">
|
||||
<button type="button" id="scan-code4-btn" class="fetch-isbn-button">Barcode scannen</button>
|
||||
<div class="form-group">
|
||||
<label for="scan_mode">Erfassungs-Modus:</label>
|
||||
<select id="scan_mode" name="scan_mode" class="form-control" onchange="toggleScanMode()">
|
||||
<option value="single">Einzel-Code (Scanner stoppt nach 1 Scan)</option>
|
||||
<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>
|
||||
<small style="display:block; color:#666;">Einzelcode manuell setzen oder per Scanner erfassen.</small>
|
||||
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
||||
<small id="code4-scan-status" style="display:block; color:#666; margin-top:6px;"></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>
|
||||
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="item_count">Anzahl dieser Sorte</label>
|
||||
<input type="number" id="item_count" name="item_count" min="1" max="100" value="1">
|
||||
<small style="display:block; color:#666;">Es werden eigene Items erstellt, aber in der Übersicht als ein Artikel gebündelt angezeigt.</small>
|
||||
|
||||
<div class="form-group" id="primary_code_group">
|
||||
<label for="code_4">Basis-Code</label>
|
||||
<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 class="form-group">
|
||||
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label>
|
||||
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea>
|
||||
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Scanner-Eingaben werden bei Mehrfachanzahl automatisch hier angefügt.</small>
|
||||
|
||||
<div class="form-group" id="individual_codes_group">
|
||||
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
|
||||
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B. ABC-001 ABC-002" class="form-control"></textarea>
|
||||
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
|
||||
</div>
|
||||
<!-- Image upload (hidden for library mode) -->
|
||||
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
||||
@@ -904,7 +957,7 @@
|
||||
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
|
||||
</div>
|
||||
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
||||
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Das Buchcover wird automatisch heruntergeladen.</small>
|
||||
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
||||
<div id="book-info-container" class="book-info-container"></div>
|
||||
</div>
|
||||
|
||||
@@ -920,7 +973,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
||||
<!-- CSV Help Modal -->
|
||||
<div id="csv-help-modal" class="popup-overlay" style="display:none;">
|
||||
<div class="duplicate-code-popup" style="max-width:760px;">
|
||||
<button class="popup-close-x" onclick="hideCsvHelp()">×</button>
|
||||
<div class="popup-content">
|
||||
<h3>CSV Import Format</h3>
|
||||
<p id="csv-help-text" style="text-align:left; color:var(--ui-text);"></p>
|
||||
<div style="margin-top:12px; text-align:center;">
|
||||
<button class="popup-close-button" onclick="hideCsvHelp()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadSampleCsv(scope) {
|
||||
let headers = ['name','ort','beschreibung','filter1','filter2','filter3','anschaffungsjahr','anschaffungskosten','code_4','anzahl','isbn','item_type'];
|
||||
let exampleInventory = ['Projektor','Aula','HD-Projektor für Präsentationen','Medien','Technik','','2019','450.00','PRJ-001','1','','general'];
|
||||
let exampleLibrary = ['Harry Potter und der Stein der Weisen','Bibliothek','Kinderbuch von J.K. Rowling','Belletristik','','','1997','12.99','HP-001','1','9783551354013','book'];
|
||||
let row = exampleInventory;
|
||||
if (scope === 'library') row = exampleLibrary;
|
||||
|
||||
const csvContent = [headers.join(','), row.map(v=> '"'+String(v).replace(/"/g,'""')+'"').join(',')].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = scope === 'library' ? 'sample_library_import.csv' : 'sample_inventory_import.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function showCsvHelp(scope) {
|
||||
const invText = `Erlaubte Spalten (Reihenfolge beliebig): name, ort, beschreibung, filter1, filter2, filter3, anschaffungsjahr, anschaffungskosten, code_4, anzahl, isbn, item_type.\n\n` +
|
||||
`Hinweise:\n- Trenne Felder per Komma. Textfelder sollten in Anführungszeichen stehen, wenn sie Kommas enthalten.\n- Für Bibliotheks-Import ist eine gültige ISBN pro Zeile erforderlich (ISBN-10 oder ISBN-13).\n- 'code_4' kann leer bleiben; das System erzeugt in diesem Fall Codes.\n- 'anzahl' legt die Anzahl physischer Exemplare für diese Zeile fest.`;
|
||||
const libText = invText;
|
||||
document.getElementById('csv-help-text').textContent = scope === 'library' ? libText : invText;
|
||||
document.getElementById('csv-help-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideCsvHelp() {
|
||||
document.getElementById('csv-help-modal').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
||||
|
||||
@@ -933,12 +1032,12 @@
|
||||
|
||||
// Global variable to store fetched book data
|
||||
let currentBookData = null;
|
||||
let code4ScannerInstance = null;
|
||||
let code4ScannerRunning = false;
|
||||
let code4LastScanned = '';
|
||||
let code4LastScannedAt = 0;
|
||||
let isbnScannerInstance = null;
|
||||
let isbnScannerRunning = false;
|
||||
|
||||
// Quagga2 engine state management properties
|
||||
let scannerRunning = false;
|
||||
let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
|
||||
var code4LastScanned = '';
|
||||
var code4LastScannedAt = 0;
|
||||
|
||||
function setCode4ScanStatus(message, isError = false) {
|
||||
const statusEl = document.getElementById('code4-scan-status');
|
||||
@@ -965,93 +1064,256 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function appendIndividualCode(scannedCode) {
|
||||
const textarea = document.getElementById('individual_codes');
|
||||
const itemCountInput = document.getElementById('item_count');
|
||||
if (!textarea || !itemCountInput) return false;
|
||||
function updateIsbnLiveValidation() {
|
||||
const isbnField = document.getElementById('isbn');
|
||||
const statusEl = document.getElementById('isbn-scan-status');
|
||||
if (!isbnField || !statusEl) return;
|
||||
|
||||
const itemCount = Math.max(1, parseInt(itemCountInput.value || '1', 10));
|
||||
if (itemCount <= 1) return false;
|
||||
const rawValue = isbnField.value.trim();
|
||||
isbnField.classList.remove('code-valid', 'code-invalid');
|
||||
|
||||
const existing = textarea.value
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (existing.includes(scannedCode)) {
|
||||
setCode4ScanStatus(`Code ${scannedCode} ist bereits eingetragen.`, true);
|
||||
return true;
|
||||
if (!rawValue) {
|
||||
statusEl.textContent = 'Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.';
|
||||
statusEl.style.color = '#666';
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.length >= itemCount) {
|
||||
setCode4ScanStatus('Es sind bereits genug Einzelcodes für die gewählte Anzahl eingetragen.', true);
|
||||
return true;
|
||||
if (normalizeIsbnClient(rawValue)) {
|
||||
isbnField.classList.add('code-valid');
|
||||
statusEl.textContent = 'Gültiges ISBN-Format erkannt. Der Wert kann gespeichert werden.';
|
||||
statusEl.style.color = '#666';
|
||||
return;
|
||||
}
|
||||
|
||||
existing.push(scannedCode);
|
||||
textarea.value = existing.join('\n');
|
||||
setCode4ScanStatus(`Code ${scannedCode} hinzugefügt (${existing.length}/${itemCount}).`);
|
||||
return true;
|
||||
isbnField.classList.add('code-invalid');
|
||||
statusEl.textContent = 'Kein gültiges ISBN-10/13-Format erkannt. Der Wert wird trotzdem akzeptiert.';
|
||||
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() {
|
||||
const scannerBox = document.getElementById('code4-scanner');
|
||||
const scanButton = document.getElementById('scan-code4-btn');
|
||||
const codeField = document.getElementById('code_4');
|
||||
const individualCodesArea = document.getElementById('individual_codes');
|
||||
const scanMode = document.getElementById('scan_mode').value;
|
||||
|
||||
if (!scannerBox || !scanButton || !codeField) return;
|
||||
|
||||
// Stop ISBN scanner if currently running to avoid camera conflicts.
|
||||
if (isbnScannerRunning && isbnScannerInstance) {
|
||||
isbnScannerInstance.clear().catch(() => {});
|
||||
const isbnScannerBox = document.getElementById('isbn-scanner');
|
||||
// Beende evtl. laufenden ISBN Scanner
|
||||
const isbnScannerBox = document.getElementById('isbn-scanner');
|
||||
if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
isbnScannerBox.style.display = 'none';
|
||||
const isbnScanButton = document.getElementById('scan-isbn-btn');
|
||||
if (isbnScannerBox) isbnScannerBox.style.display = 'none';
|
||||
if (isbnScanButton) isbnScanButton.textContent = 'ISBN scannen';
|
||||
isbnScannerRunning = false;
|
||||
if (isbnScanButton) isbnScanButton.textContent = 'Barcode scannen';
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||
}
|
||||
|
||||
if (code4ScannerRunning && code4ScannerInstance) {
|
||||
code4ScannerInstance.clear().catch(() => {});
|
||||
// Scanner ausschalten, wenn er bereits läuft
|
||||
if (scannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'Barcode scannen';
|
||||
code4ScannerRunning = false;
|
||||
setCode4ScanStatus('Scanner gestoppt.');
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Scanner starten
|
||||
scannerBox.style.display = 'block';
|
||||
scanButton.textContent = 'Scanner stoppen';
|
||||
code4ScannerInstance = new Html5QrcodeScanner('code4-scanner', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
code4ScannerRunning = true;
|
||||
setCode4ScanStatus('Scanner läuft. Mehrere Codes nacheinander möglich.');
|
||||
scanButton.textContent = scanMode === 'continuous' ? 'Fortlaufenden Scan stoppen' : 'Scanner stoppen';
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner initialisiert. Richte die Kamera auf den Barcode...');
|
||||
|
||||
code4ScannerInstance.render((decodedText) => {
|
||||
const scannedCode = String(decodedText || '').trim();
|
||||
if (!scannedCode) return;
|
||||
if (typeof runEngineInitialization === 'function') {
|
||||
runEngineInitialization(
|
||||
'#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();
|
||||
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) {
|
||||
return;
|
||||
}
|
||||
code4LastScanned = scannedCode;
|
||||
code4LastScannedAt = now;
|
||||
} else if (scanMode === 'continuous') {
|
||||
let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
|
||||
|
||||
const usedForIndividual = appendIndividualCode(scannedCode);
|
||||
if (!usedForIndividual) {
|
||||
codeField.value = scannedCode;
|
||||
validateCodeField(codeField);
|
||||
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
|
||||
}
|
||||
}, () => {});
|
||||
if (!codeField.value) {
|
||||
codeField.value = decodedText;
|
||||
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() {
|
||||
if (!libraryModuleEnabled) {
|
||||
setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
||||
return;
|
||||
}
|
||||
const scannerBox = document.getElementById('isbn-scanner');
|
||||
@@ -1059,59 +1321,75 @@
|
||||
const isbnField = document.getElementById('isbn');
|
||||
if (!scannerBox || !scanButton || !isbnField) return;
|
||||
|
||||
// Stop Code_4 scanner if currently running to avoid camera conflicts.
|
||||
if (code4ScannerRunning && code4ScannerInstance) {
|
||||
code4ScannerInstance.clear().catch(() => {});
|
||||
const codeScannerBox = document.getElementById('code4-scanner');
|
||||
const codeScanButton = document.getElementById('scan-code4-btn');
|
||||
if (codeScannerBox) codeScannerBox.style.display = 'none';
|
||||
if (codeScanButton) codeScanButton.textContent = 'Barcode scannen';
|
||||
code4ScannerRunning = false;
|
||||
setCode4ScanStatus('Scanner gestoppt.');
|
||||
const codeScannerBox = document.getElementById('code4-scanner');
|
||||
const codeScanButton = document.getElementById('scan-code4-btn');
|
||||
if (codeScannerBox && codeScannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
codeScannerBox.style.display = 'none';
|
||||
codeScanButton.textContent = 'Barcode scannen';
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||
}
|
||||
|
||||
if (isbnScannerRunning && isbnScannerInstance) {
|
||||
isbnScannerInstance.clear().catch(() => {});
|
||||
if (scannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'ISBN scannen';
|
||||
isbnScannerRunning = false;
|
||||
setIsbnScanStatus('Scanner gestoppt.');
|
||||
scanButton.textContent = 'Barcode scannen';
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||
return;
|
||||
}
|
||||
|
||||
scannerBox.style.display = 'block';
|
||||
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) => {
|
||||
const scannedCode = String(decodedText || '').trim();
|
||||
if (!scannedCode) return;
|
||||
runEngineInitialization(
|
||||
'#isbn-scanner',
|
||||
function(decodedText) {
|
||||
killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'Barcode scannen';
|
||||
|
||||
const normalizedIsbn = normalizeIsbnClient(scannedCode);
|
||||
if (!normalizedIsbn) {
|
||||
setIsbnScanStatus('Kein gültiger ISBN-Barcode erkannt. Bitte erneut scannen.', true);
|
||||
return;
|
||||
}
|
||||
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
|
||||
isbnField.value = normalizedIsbn || decodedText;
|
||||
if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
|
||||
|
||||
isbnField.value = normalizedIsbn;
|
||||
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
||||
|
||||
isbnScannerInstance.clear().catch(() => {});
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'ISBN scannen';
|
||||
isbnScannerRunning = false;
|
||||
|
||||
// Automatically load book metadata after scan.
|
||||
fetchBookInfo('upload');
|
||||
}, () => {});
|
||||
if (normalizedIsbn) {
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
||||
if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
|
||||
} else {
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
|
||||
}
|
||||
},
|
||||
'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
|
||||
function loadPredefinedFilterValues(filterNumber) {
|
||||
fetch(`/get_predefined_filter_values/${filterNumber}`)
|
||||
@@ -1231,7 +1509,7 @@
|
||||
const isbn = normalizeIsbnClient(isbnField.value);
|
||||
|
||||
if (!isbn) {
|
||||
alert('Bitte eine gültige ISBN-10 oder ISBN-13 eingeben.');
|
||||
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1297,6 +1575,7 @@
|
||||
// Get form fields
|
||||
const nameField = document.getElementById('name');
|
||||
const descriptionField = document.getElementById('beschreibung');
|
||||
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
|
||||
|
||||
if (!nameField || !descriptionField) {
|
||||
alert('Fehler: Formularfelder nicht gefunden.');
|
||||
@@ -1334,6 +1613,13 @@
|
||||
nameField.value = bookTitle;
|
||||
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
|
||||
if (currentBookData.thumbnail) {
|
||||
downloadBookCover(currentBookData.thumbnail);
|
||||
@@ -1511,6 +1797,7 @@
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Exported function for use within parent layout fields
|
||||
function validateCodeField(input, excludeId = null) {
|
||||
const code = input.value.trim();
|
||||
|
||||
@@ -1636,14 +1923,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
if (isbnField) {
|
||||
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
||||
isbnField.addEventListener('blur', updateIsbnLiveValidation);
|
||||
updateIsbnLiveValidation();
|
||||
}
|
||||
|
||||
// Setup add new location button
|
||||
@@ -1678,188 +1962,11 @@
|
||||
if (typeof duplicateData.images === 'string') {
|
||||
try {
|
||||
duplicateData.images = JSON.parse(duplicateData.images);
|
||||
console.log("Parsed images from string:", duplicateData.images);
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse images string, using as single item:", duplicateData.images);
|
||||
duplicateData.images = [duplicateData.images];
|
||||
console.error("Error parsing duplicate images array:", e);
|
||||
}
|
||||
}
|
||||
console.log(`Final duplicateData.images (${Array.isArray(duplicateData.images) ? 'array' : typeof duplicateData.images}):`, duplicateData.images);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
console.log("Parsed duplicate images successfully.");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+362
-37
@@ -7,20 +7,61 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
|
||||
Each tenant can support up to 20+ users with isolated data and resource pools.
|
||||
"""
|
||||
|
||||
from flask import request, g, has_request_context
|
||||
from flask import request, g, session, has_request_context
|
||||
from functools import wraps
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ipaddress
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TENANT_REGISTRY_MTIME = None
|
||||
|
||||
|
||||
def _load_tenant_registry_from_config():
|
||||
registry = {}
|
||||
config_path = getattr(cfg, 'CONFIG_PATH', None)
|
||||
if config_path and os.path.isfile(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as handle:
|
||||
config = json.load(handle)
|
||||
tenants = config.get('tenants', {})
|
||||
if isinstance(tenants, dict):
|
||||
registry.update(tenants)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load tenant registry from config: %s", exc)
|
||||
elif isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
|
||||
registry.update(cfg.TENANT_CONFIGS)
|
||||
return registry
|
||||
|
||||
|
||||
def _refresh_tenant_registry():
|
||||
global TENANT_REGISTRY, _TENANT_REGISTRY_MTIME
|
||||
config_path = getattr(cfg, 'CONFIG_PATH', None)
|
||||
|
||||
current_mtime = None
|
||||
if config_path and os.path.isfile(config_path):
|
||||
try:
|
||||
current_mtime = os.path.getmtime(config_path)
|
||||
except OSError:
|
||||
current_mtime = None
|
||||
|
||||
if current_mtime == _TENANT_REGISTRY_MTIME:
|
||||
return TENANT_REGISTRY
|
||||
|
||||
TENANT_REGISTRY.clear()
|
||||
TENANT_REGISTRY.update(_load_tenant_registry_from_config())
|
||||
_TENANT_REGISTRY_MTIME = current_mtime
|
||||
return TENANT_REGISTRY
|
||||
|
||||
|
||||
# Tenant registry: maps subdomain/tenant_id to database name
|
||||
TENANT_REGISTRY = {}
|
||||
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
|
||||
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
|
||||
TENANT_REGISTRY = _load_tenant_registry_from_config()
|
||||
|
||||
|
||||
def _get_nested_value(source, path, default=None):
|
||||
@@ -65,6 +106,27 @@ def _parse_port_from_host(host):
|
||||
return host, None
|
||||
|
||||
|
||||
def _first_host_token(value):
|
||||
raw = str(value or '').strip()
|
||||
if not raw:
|
||||
return ''
|
||||
return raw.split(',', 1)[0].strip().lower()
|
||||
|
||||
|
||||
def _request_host_candidates():
|
||||
candidates = []
|
||||
for header_name in ('X-Forwarded-Host', 'X-Original-Host', 'Host'):
|
||||
token = _first_host_token(request.headers.get(header_name, ''))
|
||||
if token and token not in candidates:
|
||||
candidates.append(token)
|
||||
|
||||
direct_host = _first_host_token(getattr(request, 'host', ''))
|
||||
if direct_host and direct_host not in candidates:
|
||||
candidates.append(direct_host)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _tenant_id_for_port(port):
|
||||
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
|
||||
for tenant_id, config in TENANT_REGISTRY.items():
|
||||
@@ -88,8 +150,37 @@ def _tenant_id_for_port(port):
|
||||
return None
|
||||
|
||||
|
||||
def _find_registered_tenant_id(candidate):
|
||||
candidate = str(candidate or '').strip()
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
if candidate in TENANT_REGISTRY:
|
||||
return candidate
|
||||
|
||||
lowered = candidate.lower()
|
||||
for tenant_id in TENANT_REGISTRY:
|
||||
if str(tenant_id).lower() == lowered:
|
||||
return tenant_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_ip_host(hostname):
|
||||
hostname = str(hostname or '').strip()
|
||||
if not hostname:
|
||||
return False
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def get_tenant_config(tenant_id=None):
|
||||
"""Return the registered config for a tenant, falling back to default."""
|
||||
_refresh_tenant_registry()
|
||||
|
||||
if tenant_id is None:
|
||||
ctx = get_tenant_context()
|
||||
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
|
||||
@@ -121,6 +212,7 @@ def _module_name_candidates(module_name):
|
||||
candidates = [normalized]
|
||||
alias_groups = {
|
||||
'inventory': {'inventory', 'inventar'},
|
||||
'terminplan': {'terminplan', 'terminplaner', 'termin', 'appointments'},
|
||||
'library': {'library', 'bib', 'bibliothek'},
|
||||
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
|
||||
}
|
||||
@@ -183,6 +275,183 @@ def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _parse_datetime_value(value):
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, datetime.datetime):
|
||||
parsed = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(text.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def get_tenant_trial_config(tenant_id=None):
|
||||
"""Return the optional trial/demo lifecycle settings for a tenant."""
|
||||
config = get_tenant_config(tenant_id)
|
||||
trial_config = _get_nested_value(config, ['trial'], {})
|
||||
if not isinstance(trial_config, dict):
|
||||
trial_config = {}
|
||||
|
||||
demo_config = _get_nested_value(config, ['demo'], {})
|
||||
if isinstance(demo_config, dict):
|
||||
merged = dict(demo_config)
|
||||
merged.update(trial_config)
|
||||
trial_config = merged
|
||||
|
||||
return trial_config
|
||||
|
||||
|
||||
def get_tenant_trial_status(tenant_id=None, now=None):
|
||||
"""Compute the current trial status for a tenant.
|
||||
|
||||
The config may define either an absolute "expires_at" timestamp or a
|
||||
relative lifetime via "started_at"/"created_at" plus one of
|
||||
"expires_after_days", "ttl_days", or "days".
|
||||
"""
|
||||
trial_config = get_tenant_trial_config(tenant_id)
|
||||
now = now or datetime.datetime.now()
|
||||
|
||||
enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
|
||||
if not enabled:
|
||||
return {
|
||||
'enabled': False,
|
||||
'expired': False,
|
||||
'auto_delete': bool(trial_config.get('auto_delete', False)),
|
||||
'started_at': None,
|
||||
'expires_at': None,
|
||||
'days_left': None,
|
||||
}
|
||||
|
||||
started_at = _parse_datetime_value(
|
||||
trial_config.get('started_at')
|
||||
or trial_config.get('created_at')
|
||||
or trial_config.get('activated_at')
|
||||
)
|
||||
expires_at = _parse_datetime_value(trial_config.get('expires_at'))
|
||||
|
||||
if expires_at is None:
|
||||
duration_days = trial_config.get('expires_after_days')
|
||||
if duration_days is None:
|
||||
duration_days = trial_config.get('ttl_days')
|
||||
if duration_days is None:
|
||||
duration_days = trial_config.get('days')
|
||||
try:
|
||||
duration_days = int(duration_days) if duration_days is not None else None
|
||||
except (TypeError, ValueError):
|
||||
duration_days = None
|
||||
|
||||
if duration_days is not None:
|
||||
base_time = started_at or now
|
||||
expires_at = base_time + datetime.timedelta(days=max(0, duration_days))
|
||||
|
||||
expired = bool(expires_at and now >= expires_at)
|
||||
days_left = None
|
||||
if expires_at:
|
||||
remaining = expires_at - now
|
||||
days_left = max(0, int(remaining.total_seconds() // 86400))
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'expired': expired,
|
||||
'auto_delete': bool(trial_config.get('auto_delete', False)),
|
||||
'started_at': started_at,
|
||||
'expires_at': expires_at,
|
||||
'days_left': days_left,
|
||||
}
|
||||
|
||||
|
||||
def _get_tenant_db_name_from_config(tenant_id):
|
||||
config = get_tenant_config(tenant_id)
|
||||
explicit_db = None
|
||||
if isinstance(config, dict):
|
||||
explicit_db = config.get('db') or config.get('db_name')
|
||||
|
||||
if explicit_db:
|
||||
return _normalize_db_name(explicit_db)
|
||||
|
||||
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in str(tenant_id).lower())
|
||||
return f'inventar_{sanitized}' if sanitized else cfg.MONGODB_DB
|
||||
|
||||
|
||||
def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
|
||||
"""Delete a tenant's runtime data and optionally remove its config entry."""
|
||||
tenant_id = str(tenant_id or '').strip()
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
db_name = _get_tenant_db_name_from_config(tenant_id)
|
||||
|
||||
if drop_database:
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
try:
|
||||
client.drop_database(db_name)
|
||||
finally:
|
||||
client.close()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to drop tenant database %s for %s: %s", db_name, tenant_id, exc)
|
||||
|
||||
if remove_from_config:
|
||||
try:
|
||||
config_path = getattr(cfg, 'CONFIG_PATH', None)
|
||||
if config_path and os.path.isfile(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as handle:
|
||||
config = json.load(handle)
|
||||
|
||||
tenants = config.get('tenants', {})
|
||||
if not isinstance(tenants, dict):
|
||||
tenants = {}
|
||||
|
||||
aliases = set(_tenant_db_aliases(tenant_id))
|
||||
aliases.add(tenant_id)
|
||||
lowered = tenant_id.lower()
|
||||
if lowered.startswith('schule'):
|
||||
aliases.add('school' + lowered[len('schule'):])
|
||||
elif lowered.startswith('school'):
|
||||
aliases.add('schule' + lowered[len('school'):])
|
||||
|
||||
removed_any = False
|
||||
for alias in aliases:
|
||||
if alias in tenants:
|
||||
tenants.pop(alias, None)
|
||||
removed_any = True
|
||||
|
||||
if removed_any:
|
||||
config['tenants'] = tenants
|
||||
with open(config_path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(config, handle, indent=4, ensure_ascii=False)
|
||||
|
||||
for alias in [tenant_id, *_tenant_db_aliases(tenant_id)]:
|
||||
TENANT_REGISTRY.pop(alias, None)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to remove tenant config for %s: %s", tenant_id, exc)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def purge_expired_trial_tenants(now=None):
|
||||
"""Delete expired trial tenants that opted into auto-delete."""
|
||||
now = now or datetime.datetime.now()
|
||||
purged_tenants = []
|
||||
|
||||
for tenant_id in list(TENANT_REGISTRY.keys()):
|
||||
status = get_tenant_trial_status(tenant_id, now=now)
|
||||
if status.get('enabled') and status.get('expired') and status.get('auto_delete'):
|
||||
if delete_tenant(tenant_id):
|
||||
purged_tenants.append(tenant_id)
|
||||
|
||||
return purged_tenants
|
||||
|
||||
|
||||
class TenantContext:
|
||||
"""
|
||||
Manages current tenant context for request lifecycle.
|
||||
@@ -204,48 +473,104 @@ class TenantContext:
|
||||
if not has_request_context():
|
||||
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)
|
||||
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
||||
if tenant_from_header:
|
||||
self.tenant_id = tenant_from_header
|
||||
self.config = get_tenant_config(tenant_from_header)
|
||||
return self._get_db_name(tenant_from_header)
|
||||
matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
|
||||
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 2: Port-based tenant mapping
|
||||
host = request.host.lower()
|
||||
_, port = _parse_port_from_host(host)
|
||||
self.port = port
|
||||
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
|
||||
if port:
|
||||
tenant_from_port = _tenant_id_for_port(port)
|
||||
if tenant_from_port:
|
||||
self.tenant_id = tenant_from_port
|
||||
self.config = get_tenant_config(tenant_from_port)
|
||||
logger.info(
|
||||
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(tenant_from_port)
|
||||
logger.info(f"Tenant port not mapped: host={host} port={port}")
|
||||
# Priority 2: Port/host based tenant mapping
|
||||
host_candidates = _request_host_candidates()
|
||||
primary_host = host_candidates[0] if host_candidates else _first_host_token(getattr(request, 'host', ''))
|
||||
logger.info(
|
||||
"Tenant resolution start: request.host=%s host_candidates=%s request.headers=%s",
|
||||
getattr(request, 'host', ''),
|
||||
host_candidates,
|
||||
dict(request.headers),
|
||||
)
|
||||
|
||||
for host in host_candidates:
|
||||
hostname, port = _parse_port_from_host(host)
|
||||
if port:
|
||||
self.port = port
|
||||
tenant_from_port = _tenant_id_for_port(port)
|
||||
if tenant_from_port:
|
||||
self.tenant_id = tenant_from_port
|
||||
self.config = get_tenant_config(tenant_from_port)
|
||||
session['tenant_id'] = tenant_from_port
|
||||
logger.info(
|
||||
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(tenant_from_port)
|
||||
logger.info(f"Tenant port not mapped: host={host} port={port}")
|
||||
|
||||
# Priority 3: Subdomain extraction
|
||||
parts = host.split('.')
|
||||
for host in host_candidates:
|
||||
host_without_port, _ = _parse_port_from_host(host)
|
||||
host_without_port = (host_without_port or '').strip().lower()
|
||||
if not host_without_port:
|
||||
continue
|
||||
|
||||
# Extract subdomain from host
|
||||
# Examples: schule1.example.com → schule1
|
||||
# app.example.com → app (skip wildcard/app)
|
||||
if len(parts) >= 3:
|
||||
potential_subdomain = parts[0]
|
||||
|
||||
# Filter out common non-tenant subdomains
|
||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||
self.subdomain = potential_subdomain
|
||||
self.tenant_id = potential_subdomain
|
||||
self.config = get_tenant_config(potential_subdomain)
|
||||
direct_host_match = _find_registered_tenant_id(host_without_port)
|
||||
if direct_host_match:
|
||||
self.subdomain = host_without_port
|
||||
self.tenant_id = direct_host_match
|
||||
self.config = get_tenant_config(direct_host_match)
|
||||
session['tenant_id'] = direct_host_match
|
||||
logger.info(
|
||||
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}"
|
||||
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(potential_subdomain)
|
||||
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
|
||||
return self._get_db_name(direct_host_match)
|
||||
|
||||
if host_without_port and not _is_ip_host(host_without_port):
|
||||
parts = host_without_port.split('.')
|
||||
if len(parts) >= 2:
|
||||
potential_subdomain = parts[0]
|
||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||
matched_tenant = _find_registered_tenant_id(potential_subdomain)
|
||||
if 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:
|
||||
self.subdomain = potential_subdomain
|
||||
self.tenant_id = matched_tenant
|
||||
self.config = get_tenant_config(matched_tenant)
|
||||
session['tenant_id'] = matched_tenant
|
||||
logger.info(
|
||||
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(matched_tenant)
|
||||
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
|
||||
else:
|
||||
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
|
||||
|
||||
# Priority 4: sticky tenant from the authenticated session
|
||||
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
|
||||
if session_tenant:
|
||||
self.tenant_id = session_tenant
|
||||
self.config = get_tenant_config(session_tenant)
|
||||
logger.info(
|
||||
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(session_tenant)
|
||||
|
||||
# Fallback to default tenant if no tenant identifier found.
|
||||
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
|
||||
|
||||
+14
@@ -19,6 +19,17 @@
|
||||
"cert": "Web/certs/cert.pem",
|
||||
"key": "Web/certs/key.pem"
|
||||
},
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": true,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from_address": "",
|
||||
"default_sender_name": "Invario Inventurprogramm",
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
"images": {
|
||||
"thumbnail_size": [
|
||||
150,
|
||||
@@ -42,6 +53,9 @@
|
||||
"inventory": {
|
||||
"enabled": true
|
||||
},
|
||||
"terminplan": {
|
||||
"enabled": true
|
||||
},
|
||||
"library": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: inventar_default
|
||||
|
||||
@@ -66,7 +67,7 @@ services:
|
||||
PYTHON_VERSION: "3.13"
|
||||
OPTIMIZATION_LEVEL: 2
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -95,6 +96,7 @@ services:
|
||||
INVENTAR_MONGODB_HOST: mongodb
|
||||
INVENTAR_MONGODB_PORT: "27017"
|
||||
INVENTAR_MONGODB_DB: inventar_default
|
||||
INVENTAR_CONFIG_PATH: /app/config.json
|
||||
|
||||
# Redis Configuration (for sessions and caching)
|
||||
INVENTAR_REDIS_HOST: redis
|
||||
|
||||
+467
-201
@@ -1,75 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
# Script to manage multitenant deployment
|
||||
# Allows adding, removing, and restarting tenants without downtime for others
|
||||
|
||||
# Skript zur Verwaltung von Multitenant-Deployments
|
||||
|
||||
if [ ! -f "docker-compose-multitenant.yml" ]; then
|
||||
echo "Error: docker-compose-multitenant.yml not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve script directory so config paths are deterministic even when called via sudo
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||
|
||||
ensure_runtime_config_json() {
|
||||
local config_path backup_path
|
||||
config_path="$CONFIG_FILE"
|
||||
# === HILFSFUNKTIONEN ===
|
||||
|
||||
ensure_runtime_config_json() {
|
||||
local config_path="$CONFIG_FILE"
|
||||
local backup_path
|
||||
if [ -d "$config_path" ]; then
|
||||
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
|
||||
mv "$config_path" "$backup_path"
|
||||
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
||||
fi
|
||||
|
||||
if [ ! -f "$config_path" ]; then
|
||||
cat > "$config_path" <<'EOF'
|
||||
{
|
||||
"ver": "2.6.5",
|
||||
"dbg": false,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"mongodb": {
|
||||
"host": "mongodb",
|
||||
"port": 27017,
|
||||
"db": "Inventarsystem"
|
||||
},
|
||||
"modules": {
|
||||
"library": {
|
||||
"enabled": false
|
||||
},
|
||||
"student_cards": {
|
||||
"enabled": false,
|
||||
"default_borrow_days": 14,
|
||||
"max_borrow_days": 365
|
||||
}
|
||||
}
|
||||
"tenants": {}
|
||||
}
|
||||
EOF
|
||||
echo "Created default runtime config at $config_path"
|
||||
fi
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
|
||||
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
|
||||
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
||||
echo " restart-all Restart all application containers (zero-downtime reload)"
|
||||
echo " list List active tenants"
|
||||
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./manage-tenant.sh add school_a 10001"
|
||||
echo " ./manage-tenant.sh remove test_tenant"
|
||||
echo " ./manage-tenant.sh module school_a inventory=off library=on"
|
||||
echo " ./manage-tenant.sh restart-all"
|
||||
echo " ./manage-tenant.sh -h"
|
||||
exit 1
|
||||
get_tenant_aliases() {
|
||||
local tenant_id="$1"
|
||||
local normalized alias
|
||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||
printf '%s\n' "$tenant_id"
|
||||
if [[ "$normalized" == schule* ]]; then
|
||||
alias="school${normalized#schule}"
|
||||
if [[ "$alias" != "$tenant_id" ]]; then
|
||||
printf '%s\n' "$alias"
|
||||
fi
|
||||
elif [[ "$normalized" == school* ]]; then
|
||||
alias="schule${normalized#school}"
|
||||
if [[ "$alias" != "$tenant_id" ]]; then
|
||||
printf '%s\n' "$alias"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
register_tenant_port() {
|
||||
@@ -87,15 +65,25 @@ with open(path, 'r', encoding='utf-8') as f:
|
||||
tenants = cfg.get('tenants')
|
||||
if tenants is None or not isinstance(tenants, dict):
|
||||
tenants = {}
|
||||
aliases = {tenant_id}
|
||||
normalized = tenant_id.lower()
|
||||
if normalized.startswith('schule'):
|
||||
aliases.add('school' + normalized[len('schule'):])
|
||||
elif normalized.startswith('school'):
|
||||
aliases.add('schule' + normalized[len('school'):])
|
||||
for tid, conf in tenants.items():
|
||||
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
|
||||
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
|
||||
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
existing = tenants.get(tenant_id)
|
||||
if existing is None or not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing = {}
|
||||
for alias in aliases:
|
||||
alias_cfg = tenants.get(alias)
|
||||
if isinstance(alias_cfg, dict):
|
||||
existing = alias_cfg
|
||||
break
|
||||
existing['port'] = int(port_str)
|
||||
tenants[tenant_id] = existing
|
||||
for alias in aliases:
|
||||
tenants[alias] = dict(existing)
|
||||
cfg['tenants'] = tenants
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
@@ -109,61 +97,235 @@ PY
|
||||
fi
|
||||
}
|
||||
|
||||
write_trial_tenant_config() {
|
||||
local tenant_id="$1"
|
||||
local port="$2"
|
||||
local trial_days="$3"
|
||||
|
||||
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port" "$trial_days"
|
||||
import json, sys, os, datetime
|
||||
|
||||
path, tenant_id, port_str, trial_days_str = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||
if not os.path.isfile(path):
|
||||
print(f"Error: config file not found: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
tenants = cfg.get('tenants')
|
||||
if tenants is None or not isinstance(tenants, dict):
|
||||
tenants = {}
|
||||
|
||||
aliases = {tenant_id}
|
||||
normalized = tenant_id.lower()
|
||||
if normalized.startswith('schule'):
|
||||
aliases.add('school' + normalized[len('schule'):])
|
||||
elif normalized.startswith('school'):
|
||||
aliases.add('schule' + normalized[len('school'):])
|
||||
|
||||
for tid, conf in tenants.items():
|
||||
if port_str and isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
|
||||
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
trial_days = max(1, int(trial_days_str))
|
||||
except ValueError:
|
||||
print(f"Error: trial days must be numeric, got {trial_days_str!r}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
trial_config = {
|
||||
'enabled': True,
|
||||
'auto_delete': True,
|
||||
'days': trial_days,
|
||||
'ttl_days': trial_days,
|
||||
'expires_after_days': trial_days,
|
||||
'started_at': now,
|
||||
'created_at': now,
|
||||
}
|
||||
|
||||
existing = {}
|
||||
for alias in aliases:
|
||||
alias_cfg = tenants.get(alias)
|
||||
if isinstance(alias_cfg, dict):
|
||||
existing = alias_cfg
|
||||
break
|
||||
|
||||
if port_str:
|
||||
existing['port'] = int(port_str)
|
||||
existing['modules'] = {
|
||||
'inventory': {'enabled': True},
|
||||
'library': {'enabled': True},
|
||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||
'terminplan': {'enabled': True},
|
||||
'mail': {'enabled': True},
|
||||
}
|
||||
existing['trial'] = trial_config
|
||||
|
||||
for alias in aliases:
|
||||
tenants[alias] = dict(existing)
|
||||
|
||||
cfg['tenants'] = tenants
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
|
||||
print(f"Configured trial tenant {tenant_id} with {trial_days} day(s) and auto-delete enabled")
|
||||
PY
|
||||
then
|
||||
echo "Trial tenant $tenant_id configured in config.json"
|
||||
else
|
||||
echo "Failed to configure trial tenant $tenant_id"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
initialize_tenant_database() {
|
||||
local tenant_id="$1"
|
||||
local mode="$2"
|
||||
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -z "$APP_CONTAINER" ]; then
|
||||
echo "Warning: Application container is not running. Please start the multi-tenant system first."
|
||||
echo "Data will be initialized upon first access by the tenant."
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker exec "$APP_CONTAINER" python3 -c '
|
||||
import sys, re, datetime, hashlib
|
||||
sys.path.insert(0, "/app")
|
||||
sys.path.insert(0, "/app/Web")
|
||||
from Web.modules.database import settings
|
||||
from pymongo import MongoClient
|
||||
|
||||
tenant_id = sys.argv[1].lower()
|
||||
mode = sys.argv[2]
|
||||
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||
db_name = f"inventar_{sanitized}"
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[db_name]
|
||||
hashed_pw = hashlib.scrypt("admin123", salt=b"some_salt", n=16384, r=8, p=1).hex()
|
||||
|
||||
action_permissions = {
|
||||
"can_borrow": True,
|
||||
"can_insert": True,
|
||||
"can_edit": True,
|
||||
"can_delete": True,
|
||||
"can_manage_users": True,
|
||||
"can_manage_settings": True,
|
||||
"can_view_logs": True,
|
||||
}
|
||||
|
||||
page_permissions = {
|
||||
"home": True,
|
||||
"tutorial_page": True,
|
||||
"my_borrowed_items": True,
|
||||
"notifications_view": True,
|
||||
"impressum": True,
|
||||
"license": True,
|
||||
"library_view": True,
|
||||
"terminplan": True,
|
||||
"home_admin": True,
|
||||
"upload_admin": True,
|
||||
"library_admin": True,
|
||||
"admin_borrowings": True,
|
||||
"library_loans_admin": True,
|
||||
"admin_damaged_items": True,
|
||||
"admin_audit_dashboard": True,
|
||||
"logs": True,
|
||||
"manage_filters": True,
|
||||
"manage_locations": True,
|
||||
}
|
||||
|
||||
if db.users.count_documents({"Username": "admin"}) == 0:
|
||||
db.users.insert_one({
|
||||
"Username": "admin",
|
||||
"Password": hashed_pw,
|
||||
"Admin": True,
|
||||
"active_ausleihung": None,
|
||||
"name": "Admin",
|
||||
"last_name": "User",
|
||||
"IsStudent": False,
|
||||
"PermissionPreset": "full_access",
|
||||
"ActionPermissions": action_permissions,
|
||||
"PagePermissions": page_permissions,
|
||||
})
|
||||
|
||||
if mode == "trial":
|
||||
db.settings.update_one(
|
||||
{"setting_type": "tenant_trial"},
|
||||
{"$set": {
|
||||
"setting_type": "tenant_trial",
|
||||
"enabled": True,
|
||||
"auto_delete": True,
|
||||
"days": 7,
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
}},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
||||
' "$tenant_id" "$mode"
|
||||
}
|
||||
|
||||
update_runtime_ports() {
|
||||
local new_port="$1"
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local current_ports=""
|
||||
local port_list=()
|
||||
local unique_ports=()
|
||||
local first_port=""
|
||||
if [ -n "$new_port" ]; then
|
||||
if [ -f "$env_file" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
local current_ports port_list unique_ports first_port ports_csv
|
||||
|
||||
if [ -z "$new_port" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
current_ports=""
|
||||
if [ -f "$env_file" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$current_ports" ]; then
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
port_list=()
|
||||
unique_ports=()
|
||||
if [ -n "$current_ports" ]; then
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
fi
|
||||
port_list+=("$new_port")
|
||||
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||
unique_ports+=("$port")
|
||||
fi
|
||||
port_list+=("$new_port")
|
||||
done
|
||||
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||
unique_ports+=("$port")
|
||||
fi
|
||||
done
|
||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||
unique_ports=("$new_port")
|
||||
fi
|
||||
|
||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||
unique_ports=("$new_port")
|
||||
fi
|
||||
first_port="${unique_ports[0]}"
|
||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||
|
||||
first_port="${unique_ports[0]}"
|
||||
local ports_csv
|
||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" <<EOF
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=$first_port
|
||||
INVENTAR_HTTP_PORTS=$ports_csv
|
||||
EOF
|
||||
else
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
|
||||
echo "Updated runtime env ports: $ports_csv"
|
||||
fi
|
||||
|
||||
echo "Updated runtime env ports: $ports_csv"
|
||||
}
|
||||
|
||||
sync_tenant_port_map() {
|
||||
@@ -214,13 +376,14 @@ EOF
|
||||
}
|
||||
|
||||
restart_app_container() {
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local workdir="$SCRIPT_DIR"
|
||||
local env_file="$SCRIPT_DIR/.docker-build.env"
|
||||
local compose_args=()
|
||||
|
||||
ensure_runtime_config_json
|
||||
|
||||
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
|
||||
if [ -n "$HOST_WORKDIR" ]; then
|
||||
if [ -n "${HOST_WORKDIR:-}" ]; then
|
||||
workdir="$HOST_WORKDIR"
|
||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
||||
if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" )
|
||||
@@ -229,18 +392,16 @@ restart_app_container() {
|
||||
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
|
||||
fi
|
||||
else
|
||||
# Normal case: called directly from host
|
||||
compose_args+=( -f "$PWD/docker-compose-multitenant.yml" )
|
||||
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
|
||||
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
||||
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
||||
fi
|
||||
if [ -f "$env_file" ]; then
|
||||
compose_args+=( --env-file "$env_file" )
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
|
||||
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
|
||||
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
||||
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
||||
fi
|
||||
|
||||
@@ -261,9 +422,20 @@ if not os.path.isfile(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
tenants = cfg.get('tenants', {})
|
||||
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
|
||||
if not isinstance(tenants, dict):
|
||||
sys.exit(2)
|
||||
removed = tenants.pop(tenant_id, None)
|
||||
aliases = {tenant_id}
|
||||
normalized = tenant_id.lower()
|
||||
if normalized.startswith('schule'):
|
||||
aliases.add('school' + normalized[len('schule'):])
|
||||
elif normalized.startswith('school'):
|
||||
aliases.add('schule' + normalized[len('school'):])
|
||||
removed = None
|
||||
for alias in list(aliases):
|
||||
alias_cfg = tenants.get(alias)
|
||||
if isinstance(alias_cfg, dict):
|
||||
removed = alias_cfg if removed is None else removed
|
||||
tenants.pop(alias, None)
|
||||
cfg['tenants'] = tenants
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
@@ -338,24 +510,83 @@ remove_runtime_port() {
|
||||
fi
|
||||
}
|
||||
|
||||
show_help() {
|
||||
local HEADER='\033[95m'
|
||||
local BLUE='\033[94m'
|
||||
local GREEN='\033[92m'
|
||||
local YELLOW='\033[93m'
|
||||
local RED='\033[91m'
|
||||
local BOLD='\033[1m'
|
||||
local RESET='\033[0m'
|
||||
|
||||
cat << EOF
|
||||
|
||||
${HEADER}${BOLD}=== MULTI-TENANT INVENTAR MANAGER ===${RESET}
|
||||
${YELLOW}Nutzung:${RESET} $0 <befehl> [tenant_id] [optionen]
|
||||
|
||||
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
|
||||
${GREEN}add${RESET} <tenant_id> [port]
|
||||
Legt einen neuen Tenant an, registriert den Port und initialisiert
|
||||
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}).
|
||||
|
||||
${GREEN}trial${RESET} <tenant_id> [port] [tage]
|
||||
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage.
|
||||
Läuft automatisch ab und löscht sich selbst.
|
||||
|
||||
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
|
||||
Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank.
|
||||
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
|
||||
|
||||
${GREEN}restart-tenant${RESET} <tenant_id>
|
||||
Startet einen spezifischen Tenant neu, indem alle aktiven Sessions
|
||||
und der Cache in der MongoDB geleert werden (Sitzungs-Reset).
|
||||
|
||||
${GREEN}restart-all${RESET}
|
||||
Führt einen Zero-Downtime Rolling-Restart für alle App-Container durch.
|
||||
|
||||
${GREEN}list${RESET}
|
||||
Listet alle registrierten Tenants aus der 'config.json' sowie alle
|
||||
aktiven Tenant-Datenbanken aus der MongoDB auf.
|
||||
|
||||
${GREEN}module${RESET} <tenant_id> <modulname>=<on|off> ...
|
||||
Aktiviert oder deaktiviert bestimmte Features/Module für einen Tenant.
|
||||
Es können mehrere Module gleichzeitig übergeben werden.
|
||||
|
||||
${BLUE}${BOLD}GLOBALE OPTIONEN:${RESET}
|
||||
${GREEN}-h, --help${RESET}
|
||||
Zeigt diese Hilfe an.
|
||||
|
||||
${YELLOW}Beispiele:${RESET}
|
||||
$0 add schule_muenchen 8081
|
||||
$0 trial test_user 8082 14
|
||||
$0 module schule_muenchen barre_code=on leih_historie=off
|
||||
$0 remove -y test_user
|
||||
|
||||
-----------------------------------------------------------------
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ -z "${1:-}" ]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COMMAND="$1"
|
||||
TENANT_ID="${2:-}"
|
||||
|
||||
# === MAIN ROUTING BLOCK ===
|
||||
|
||||
case "$COMMAND" in
|
||||
-h|--help)
|
||||
show_help
|
||||
;;
|
||||
add)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORT_ARG="$3"
|
||||
PORT_ARG="${3:-}"
|
||||
if [ -n "$PORT_ARG" ]; then
|
||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||
echo "Error: Port must be a numeric value."
|
||||
@@ -370,130 +601,142 @@ case "$COMMAND" in
|
||||
fi
|
||||
|
||||
echo "Adding new tenant '$TENANT_ID'..."
|
||||
# Initialize tenant database via Python inside container
|
||||
echo "Initializing database for $TENANT_ID..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
|
||||
tenant_id = sys.argv[1].lower()
|
||||
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
|
||||
db_name = f'inventar_{sanitized}'
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[db_name]
|
||||
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
|
||||
if db.users.count_documents({'Username': 'admin'}) == 0:
|
||||
db.users.insert_one({
|
||||
'Username': 'admin',
|
||||
'Password': hashed_pw,
|
||||
'Admin': True,
|
||||
'active_ausleihung': None,
|
||||
'name': 'Admin',
|
||||
'last_name': 'User',
|
||||
'IsStudent': False,
|
||||
'PermissionPreset': 'full_access',
|
||||
'ActionPermissions': {
|
||||
'can_borrow': True,
|
||||
'can_insert': True,
|
||||
'can_edit': True,
|
||||
'can_delete': True,
|
||||
'can_manage_users': True,
|
||||
'can_manage_settings': True,
|
||||
'can_view_logs': True,
|
||||
},
|
||||
'PagePermissions': {
|
||||
'home': True,
|
||||
'tutorial_page': True,
|
||||
'my_borrowed_items': True,
|
||||
'notifications_view': True,
|
||||
'impressum': True,
|
||||
'license': True,
|
||||
'library_view': True,
|
||||
'terminplan': True,
|
||||
'home_admin': True,
|
||||
'upload_admin': True,
|
||||
'library_admin': True,
|
||||
'admin_borrowings': True,
|
||||
'library_loans_admin': True,
|
||||
'admin_damaged_items': True,
|
||||
'admin_audit_dashboard': True,
|
||||
'logs': True,
|
||||
'manage_filters': True,
|
||||
'manage_locations': True,
|
||||
},
|
||||
})
|
||||
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||
else
|
||||
echo "Warning: Application container is not running. Please start the multi-tenant system first."
|
||||
echo "Data will be initialized upon first access by the tenant."
|
||||
initialize_tenant_database "$TENANT_ID" "standard"
|
||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||
;;
|
||||
|
||||
trial)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORT_ARG="${3:-}"
|
||||
DAYS_ARG="${4:-7}"
|
||||
|
||||
if [ -n "$PORT_ARG" ]; then
|
||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||
echo "Error: Port must be a numeric value."
|
||||
exit 1
|
||||
fi
|
||||
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
||||
update_runtime_ports "$PORT_ARG"
|
||||
sync_tenant_port_map
|
||||
fi
|
||||
|
||||
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
|
||||
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
|
||||
echo "Initializing trial database for $TENANT_ID..."
|
||||
initialize_tenant_database "$TENANT_ID" "trial"
|
||||
echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete."
|
||||
;;
|
||||
|
||||
remove)
|
||||
FORCE_REMOVE=false
|
||||
TENANT_ARG="${2:-}"
|
||||
|
||||
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
|
||||
FORCE_REMOVE=true
|
||||
TENANT_ID="${3:-}"
|
||||
else
|
||||
TENANT_ID="$TENANT_ARG"
|
||||
fi
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id to remove."
|
||||
exit 1
|
||||
fi
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
|
||||
ctx = TenantContext()
|
||||
db_name = ctx._get_db_name(sys.argv[1])
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
client.drop_database(db_name)
|
||||
print(f'Database for tenant {sys.argv[1]} dropped.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' database removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
fi
|
||||
|
||||
port_to_remove=""
|
||||
if [ "$FORCE_REMOVE" != true ]; then
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
echo "Removal canceled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
port_to_remove=""
|
||||
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
# Zuerst die Datenbank via PyMongo hart droppen (Erzwungenes Löschen)
|
||||
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||
import sys, re
|
||||
sys.path.insert(0, '/app')
|
||||
sys.path.insert(0, '/app/Web')
|
||||
from tenant import delete_tenant, get_tenant_config
|
||||
from Web.modules.database import settings
|
||||
from pymongo import MongoClient
|
||||
|
||||
tenant_id = sys.argv[1]
|
||||
tenant_cfg = get_tenant_config(tenant_id)
|
||||
port = tenant_cfg.get('port')
|
||||
|
||||
# Datenbanknamen exakt rekonstruieren
|
||||
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||
db_name = f"inventar_{sanitized}"
|
||||
|
||||
try:
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
client.drop_database(db_name)
|
||||
print(f"MongoDB database '{db_name}' dropped successfully.", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not drop database '{db_name}': {e}", file=sys.stderr)
|
||||
|
||||
if not delete_tenant(tenant_id):
|
||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if port is not None:
|
||||
print(port)
|
||||
PY
|
||||
)"
|
||||
echo "Tenant '$TENANT_ID' database and config removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
|
||||
fi
|
||||
:
|
||||
else
|
||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
fi
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
echo "Removal canceled."
|
||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
||||
fi
|
||||
;;
|
||||
|
||||
restart-tenant)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
||||
# To restart a single tenant without restarting the global python processes,
|
||||
# we can invalidate their cache or drop their sessions collection to sign everyone out
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||
db.sessions.drop() # Force sign-out / session clear
|
||||
db.sessions.drop()
|
||||
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
||||
@@ -556,12 +799,12 @@ PY
|
||||
;;
|
||||
|
||||
module)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
||||
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pass all remaining arguments to python script
|
||||
shift 2
|
||||
|
||||
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
||||
@@ -578,11 +821,26 @@ with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
tenants = cfg.setdefault('tenants', {})
|
||||
tenant_cfg = tenants.setdefault(tenant_id, {})
|
||||
if 'port' not in tenant_cfg:
|
||||
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
|
||||
aliases = {tenant_id}
|
||||
normalized = tenant_id.lower()
|
||||
if normalized.startswith('schule'):
|
||||
aliases.add('school' + normalized[len('schule'):])
|
||||
elif normalized.startswith('school'):
|
||||
aliases.add('schule' + normalized[len('school'):])
|
||||
|
||||
tenant_cfg = None
|
||||
for alias in aliases:
|
||||
alias_cfg = tenants.get(alias)
|
||||
if isinstance(alias_cfg, dict):
|
||||
tenant_cfg = alias_cfg
|
||||
break
|
||||
if tenant_cfg is None:
|
||||
tenant_cfg = {}
|
||||
|
||||
modules = tenant_cfg.setdefault('modules', {})
|
||||
port = tenant_cfg.get('port')
|
||||
if port is None:
|
||||
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
|
||||
|
||||
for arg in module_args:
|
||||
if '=' not in arg:
|
||||
@@ -594,6 +852,14 @@ for arg in module_args:
|
||||
module_cfg['enabled'] = state
|
||||
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
|
||||
|
||||
for alias in aliases:
|
||||
alias_cfg = tenants.get(alias)
|
||||
if not isinstance(alias_cfg, dict):
|
||||
alias_cfg = {}
|
||||
alias_cfg.update(tenant_cfg)
|
||||
alias_cfg['modules'] = modules
|
||||
tenants[alias] = alias_cfg
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
PY
|
||||
@@ -614,4 +880,4 @@ PY
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
exit 0
|
||||
@@ -15,3 +15,4 @@ openpyxl
|
||||
cryptography>=42.0.0
|
||||
pywebpush
|
||||
py-vapid>=1.9.0
|
||||
beautifulsoup4
|
||||
@@ -572,7 +572,7 @@ write_runtime_compose_override() {
|
||||
services:
|
||||
app:
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
image: ${APP_IMAGE_VALUE}
|
||||
build: null
|
||||
EOF
|
||||
|
||||
Reference in New Issue
Block a user