Compare commits

...

98 Commits

Author SHA1 Message Date
Aiirondev_dev b1ca358d41 Cleanup of the front end and backend of the upload function to only allow images and no videos because of space and memory reasons as well as hardening the uploading process.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-15 01:38:35 +02:00
Aiirondev_dev 47cba5865d Removal of dupolicate function.
Release Inventarsystem / release-docker (push) Successful in 4m55s
2026-08-15 01:33:38 +02:00
Aiirondev_dev 4a776872e0 Improvement of the style for the Library Module
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-15 00:08:13 +02:00
Aiirondev_dev 55a74654db Addition of a missing ','
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 23:18:29 +02:00
Aiirondev_dev 7473334714 Changes to the Inventarsystem Workflow to Reflekt the new workflow and have an according set of buttons. So this removed all metioning of the duplication function.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 23:01:01 +02:00
Aiirondev_dev d746b61a17 feat: integrate Quagga2 scanner, background pagination, and secure fetch API
Release Inventarsystem / release-docker (push) Successful in 2m15s
Configure Quagga2 barcode scanner with environment facing mode
Add fallback keyboardScanKeydownHandler for physical keyboard-wedge scanners
Secure fetch requests by including X-CSRFToken headers
Implement loadRemainingLibraryItemsInBackground for seamless data loading
Refactor showItemDetail to accurately render videos, external URLs, and local uploa
2026-08-14 22:33:52 +02:00
Aiirondev_dev 440cafb88f Merge remote-tracking branch 'origin/main'
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-14 20:35:44 +02:00
Aiirondev_dev 9fb56e905c Renaming of the Schülerausweis to the Bibliotheksausweis 2026-08-14 20:35:31 +02:00
Aiirondev_dev 21ad639081 changes to make the default redirect endpoint in chase of page authentication error
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-14 20:20:59 +02:00
Aiirondev_dev 61d726f8f4 Final fixes of the user functions to remove the breaking encryption of the username.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 14:17:54 +02:00
Aiirondev_dev 2d7cec075c workaround for existing misused users
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 12:08:31 +02:00
Aiirondev_dev fc0fdf8472 change to the authentification system to allow the user acces o the main page
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-14 12:05:23 +02:00
Aiirondev_dev 237c79be58 changes to the user authentication
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-14 11:58:44 +02:00
Aiirondev_dev 821636908f changes to the user authentification
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-14 11:51:10 +02:00
Aiirondev_dev 1506406adc Merge remote-tracking branch 'refs/remotes/origin/main'
Release Inventarsystem / release-docker (push) Successful in 3m18s
2026-08-14 11:24:51 +02:00
Aiirondev_dev 567edc43a7 Slight changes to make the username appear correctly 2026-08-14 11:15:12 +02:00
Aiirondev_dev 28f5991fa0 slight fixes to fix the image displayment
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-13 00:23:54 +02:00
Aiirondev_dev e9b4cf0c25 slight fixes to fix the image processing
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 23:47:38 +02:00
Aiirondev_dev 99ec28f329 Introduction of the images to the detailed view to the library_table.html
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 23:10:06 +02:00
Aiirondev_dev 057c517515 Debug removal of whitelist
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 22:38:07 +02:00
Aiirondev_dev cfec33b362 Slight clean up of old functions that are not in use anymore, and inclution of Image uploading for the library uploading process
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 22:29:45 +02:00
Aiirondev_dev fb29fb91a9 Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-08-12 16:34:01 +02:00
Aiirondev_dev c3db2d9c6b Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 16:31:17 +02:00
Aiirondev_dev edc7b72a8f Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 16:26:08 +02:00
Aiirondev_dev cdf7b9c45d slight correction to also account for name and last_name
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 16:09:52 +02:00
Aiirondev_dev 5ba0944faf implementation of the user .csv importing
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 15:46:31 +02:00
Aiirondev_dev 37f514af15 implementation of the edeting function also for the Inventarsystem to complete the transformation from the old outdated system to the new and more refined one
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 14:28:46 +02:00
Aiirondev_dev 152a3ab135 corrected type mismatch
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 14:07:17 +02:00
Aiirondev_dev c2c5054814 corrected type mismatch
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 12:40:05 +02:00
Aiirondev_dev e058bd5f46 spelling mistake
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 12:30:25 +02:00
Aiirondev_dev d58958db39 Changes to habe he template name right and the right correction for the library Item and a parameter mismatch
Release Inventarsystem / release-docker (push) Successful in 2m22s
2026-08-12 12:26:10 +02:00
Aiirondev_dev 9452743660 Changes to reflekt the right inventar and bibliothek item fields
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-12 11:59:57 +02:00
Aiirondev_dev 6a3865ef24 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 11:46:59 +02:00
Aiirondev_dev 96d45710ac fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 11:45:48 +02:00
Aiirondev_dev dd3d8649a7 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 3m10s
2026-08-12 11:37:05 +02:00
Aiirondev_dev 1af2a2be06 implementation of the image
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 00:32:00 +02:00
Aiirondev_dev 8e5e434116 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 00:24:25 +02:00
Aiirondev_dev 2f9a93ee65 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 00:20:48 +02:00
Aiirondev_dev 91467a1e76 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 00:19:19 +02:00
Aiirondev_dev 3840348a2d Changes to the formating and processing of the group codes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 00:12:06 +02:00
Aiirondev_dev cdb7319c56 Changes so the edeting function can also adeit all the Codes and the entire mamunt of an itemgroup
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 23:58:23 +02:00
Aiirondev_dev 08bea97f0f Changes so the edeting function can also adeit all the Codes and the entire mamunt of an itemgroup
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 23:49:20 +02:00
Aiirondev_dev 9164cd030d Debug changes
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 23:07:13 +02:00
Aiirondev_dev 9227392787 Debug changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-11 22:56:40 +02:00
Aiirondev_dev 4917c22ae3 Debug changes
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 22:49:40 +02:00
Aiirondev_dev a2f2dd5a9e Debug changes
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 22:41:30 +02:00
Aiirondev_dev faf270ff93 Debug changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-11 22:35:43 +02:00
Aiirondev_dev beeb562ac4 Dubvug changes
Release Inventarsystem / release-docker (push) Successful in 2m32s
2026-08-11 22:21:28 +02:00
Aiirondev_dev 0199957545 Dubvug changes
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 21:35:08 +02:00
Aiirondev_dev a518adb054 Dubvug changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-11 21:27:39 +02:00
Aiirondev_dev 6a94d50d28 Additiopn to filter out the iduvidual items and
Release Inventarsystem / release-docker (push) Has been cancelled
2026-08-11 21:16:37 +02:00
Aiirondev_dev c90cef6dcf Addition of ort preloading for the edeting modal
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 15:30:26 +02:00
Aiirondev_dev b5451a4ef0 Addition of ort preloading for the edeting modal
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-11 15:12:17 +02:00
Aiirondev_dev a4afef8283 Fix of a stray redirect error
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-11 15:00:20 +02:00
Aiirondev_dev b2951eed6c Fix of broken authentication metode
Release Inventarsystem / release-docker (push) Has been cancelled
2026-08-11 10:46:38 +02:00
Aiirondev_dev 9a37c047c1 Fix of an missing parethasys
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 02:01:27 +02:00
Aiirondev_dev 7290fb4ed1 Fix of an missing parethasys
Release Inventarsystem / release-docker (push) Has been cancelled
2026-08-11 01:52:06 +02:00
Aiirondev_dev ee9ef3df6f Fix of an missing parethasys
Release Inventarsystem / release-docker (push) Has been cancelled
2026-08-11 01:09:53 +02:00
Aiirondev_dev 9f3799a77f Fix of an missing parethasys
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-11 00:57:51 +02:00
Aiirondev_dev e9006f5a07 Fix of an missing parethasys
Release Inventarsystem / release-docker (push) Has been cancelled
2026-08-11 00:38:48 +02:00
Aiirondev_dev a11bce17c5 Fixy of the api_library_items()
Release Inventarsystem / release-docker (push) Has been cancelled
2026-08-11 00:31:57 +02:00
Aiirondev_dev 743e5b1c16 Reviewed the edeting function
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-10 23:40:02 +02:00
Aiirondev_dev 4e47ef0c88 Implement library item return by code API and enhance UI for manual returns
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-10 22:17:52 +02:00
Aiirondev_dev 0911b362fd Changes to match the designated endpoint of the user when uploading an Item to dont have the system Jump between the systems.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-10 21:35:13 +02:00
Aiirondev_dev 71716f339a Add keyboard scanner support with toggle option in library table
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-10 21:23:33 +02:00
Aiirondev_dev 36ccee38cb Addition of the button as described in the Issue #17
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-10 17:41:18 +02:00
Aiirondev_dev 9255c87f57 Implement ISBN live validation and automatic lookup on input change
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-10 14:16:20 +02:00
Aiirondev_dev a6b246a92b Refactor upload form fields for clarity and enhance focus navigation
Release Inventarsystem / release-docker (push) Successful in 3m15s
2026-08-10 14:10:08 +02:00
Aiirondev_dev 25f52eeeb5 Change sfor the issue #16 with some of the described changes as addtion of a arrow pointig down or the rearange ment done
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-10 00:24:05 +02:00
Aiirondev_dev c07d4e0bdd Changes to match the designated endpoint of the user when uploading an Item to dont have the system Jump between the systems.
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-09 23:59:13 +02:00
Aiirondev_dev 0cacfb0871 Quik and more or less temporary fix for the Issue #43 further changes are not required
Release Inventarsystem / release-docker (push) Successful in 3m16s
2026-08-09 23:39:21 +02:00
Aiirondev_dev d3bfaa4580 update of an outadet approach of the user adding in the current version 2026-08-09 21:15:13 +02:00
Aiirondev_dev 36531662d3 final changes to the release edition
Release Inventarsystem / release-docker (push) Successful in 3m28s
2026-08-05 20:38:35 +02:00
Aiirondev_dev 35b87a9a98 cahnges to the release process
Release Inventarsystem / release-docker (push) Successful in 2m28s
2026-08-05 20:24:05 +02:00
Aiirondev_dev 80262aca9b additiopn of a deverlopment mode 2026-08-05 20:14:41 +02:00
Aiirondev_dev 71a8823b35 Addition of the nginx forwarding 2026-08-05 18:53:25 +02:00
Aiirondev_dev 10e7f3e70d spelling mistake fix 2026-08-05 10:12:37 +02:00
Aiirondev_dev e8f0d4bdc5 change to the tenant anagment 2026-08-05 00:36:47 +02:00
Aiirondev_dev c77c52271c changes to the manage-tenant.sh 2026-08-05 00:29:30 +02:00
Aiirondev_dev 7e00ed3151 update of an outadet approach of the user adding in the current version 2026-08-05 00:19:02 +02:00
Aiirondev_dev 39c9666c0f update of an outadet approach of the user adding in the current version 2026-08-05 00:16:19 +02:00
Aiirondev_dev 8eb240440b update of an outadet approach of the user adding in the current version 2026-08-04 23:59:41 +02:00
Aiirondev_dev 9dc6fe6eeb update of an outadet approach of the user adding in the current version 2026-08-04 23:56:39 +02:00
Aiirondev_dev acfa24d742 update of an outadet approach of the user adding in the current version 2026-08-04 23:53:12 +02:00
Aiirondev_dev 88f6b77455 fix of the json upload format 2026-08-04 23:46:42 +02:00
Aiirondev_dev bc5e08142a fix of the json upload format 2026-08-04 22:24:55 +02:00
Aiirondev_dev 373839cf03 fix of the json upload format 2026-08-04 22:17:16 +02:00
Aiirondev_dev 8e31309c55 fix of the json upload format 2026-08-04 21:55:23 +02:00
Aiirondev_dev e8391dbf1e fix of the json upload format 2026-08-04 21:50:17 +02:00
Aiirondev_dev bc274da006 fix of the json upload format 2026-08-04 21:42:52 +02:00
Aiirondev_dev f0b5edff79 fix of the json upload format 2026-08-04 21:11:13 +02:00
Aiirondev_dev 5b95c5202e fix of the json upload format 2026-08-04 21:03:08 +02:00
Aiirondev_dev 51c17d11f2 fix of the json upload format 2026-08-04 20:54:03 +02:00
Aiirondev_dev 91ddc6e864 fix of the json upload format 2026-08-04 20:44:15 +02:00
Aiirondev_dev 88f124c991 fix of the json upload format 2026-08-04 20:42:06 +02:00
Aiirondev_dev 33c65f21b6 fix of the json upload format 2026-08-03 23:55:30 +02:00
Aiirondev_dev fd242a6a0a fix of the json upload format 2026-08-03 23:45:59 +02:00
Aiirondev_dev 2892024969 fix of the json upload format 2026-08-03 23:26:51 +02:00
20 changed files with 3666 additions and 3611 deletions
+83 -9
View File
@@ -4,17 +4,20 @@ on:
push:
tags:
- "v*"
branches:
- main
workflow_dispatch:
inputs:
bump:
description: "Version bump type (major stays fixed from latest release)"
release_type:
description: "Release type"
required: false
default: "patch"
type: choice
options:
- patch
- minor
- major
- minor
- patch
- dev
permissions:
contents: write
@@ -38,12 +41,70 @@ jobs:
REPO: ${{ gitea.repository }}
EVENT_NAME: ${{ gitea.event_name }}
REF_NAME: ${{ gitea.ref_name }}
BUMP_TYPE: ${{ gitea.event.inputs.bump || 'patch' }}
RELEASE_TYPE: ${{ gitea.event.inputs.release_type || 'patch' }}
DOCKER_API_VERSION: '1.44'
run: |
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
latest_stable_tag() {
local releases_file="$1"
python3 -c "import json, sys; releases = json.load(open(sys.argv[1], encoding='utf-8')); print(next((release.get('tag_name', '').strip() for release in releases if not release.get('prerelease') and not release.get('draft')), ''))" "$releases_file"
}
next_dev_suffix() {
local releases_file="$1"
local base_tag="$2"
python3 -c "import json, re, sys; releases_path, base_tag = sys.argv[1], sys.argv[2].strip(); pattern = re.compile(r'^' + re.escape(base_tag) + r'-dev\\.(\\d+)$'); releases = json.load(open(releases_path, encoding='utf-8')); highest = max([int(match.group(1)) for release in releases for match in [pattern.match(release.get('tag_name', '').strip())] if match], default=0); print(highest + 1)" "$releases_file" "$base_tag"
}
make_dev_tag() {
local releases_file="$1"
local stable_tag="$2"
local major minor patch next_suffix
if [[ "$stable_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major=${BASH_REMATCH[1]}
minor=${BASH_REMATCH[2]}
patch=${BASH_REMATCH[3]}
else
major=0
minor=8
patch=31
fi
patch=$((patch + 1))
next_suffix="$(next_dev_suffix "$releases_file" "v${major}.${minor}.${patch}")"
printf 'v%s.%s.%s-dev.%s' "$major" "$minor" "$patch" "$next_suffix"
}
releases_file="$(mktemp)"
trap 'rm -f "${releases_file:-}"' EXIT
if [ "$EVENT_NAME" = "push" ] && [ "$REF_NAME" = "main" ]; then
prerelease=true
if ! curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases" -o "$releases_file"; then
echo "Error: could not fetch release list"
exit 1
fi
latest_tag="$(latest_stable_tag "$releases_file")"
if [ -z "$latest_tag" ]; then
latest_tag="v0.8.31"
fi
TAG="$(make_dev_tag "$releases_file" "$latest_tag")"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_TYPE" = "dev" ]; then
prerelease=true
if ! curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases" -o "$releases_file"; then
echo "Error: could not fetch release list"
exit 1
fi
latest_tag="$(latest_stable_tag "$releases_file")"
if [ -z "$latest_tag" ]; then
latest_tag="v0.8.31"
fi
TAG="$(make_dev_tag "$releases_file" "$latest_tag")"
elif [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
prerelease=false
TAG="$REF_NAME"
else
prerelease=false
latest_tag="v0.8.31"
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/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)
@@ -60,14 +121,14 @@ jobs:
major=0; minor=8; patch=31
fi
if [ "${BUMP_TYPE:-}" = "major" ]; then
if [ "${RELEASE_TYPE:-}" = "major" ]; then
major=$((major + 1)); minor=0; patch=0
elif [ "${BUMP_TYPE:-}" = "minor" ]; then
elif [ "${RELEASE_TYPE:-}" = "minor" ]; then
minor=$((minor + 1)); patch=0
else
patch=$((patch + 1))
fi
TAG="v${major}.${minor}.${patch}"
fi
@@ -109,6 +170,7 @@ jobs:
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT"
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
- name: Ensure Docker CLI is available and up to date
run: |
@@ -148,6 +210,7 @@ jobs:
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push release image
if: steps.meta.outputs.prerelease != 'true'
uses: docker/build-push-action@v6
with:
context: .
@@ -157,6 +220,16 @@ jobs:
${{ steps.meta.outputs.image }}
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
- name: Build and push prerelease image
if: steps.meta.outputs.prerelease == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}
- name: Create release-only docker bundle
run: |
mkdir -p release-bundle
@@ -253,5 +326,6 @@ jobs:
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.meta.outputs.tag }}
prerelease: ${{ steps.meta.outputs.prerelease }}
files: |
inventarsystem-docker-bundle.tar.gz
+758 -575
View File
File diff suppressed because it is too large Load Diff
+155 -10
View File
@@ -20,12 +20,30 @@ Collection Structure:
"""
from bson.objectid import ObjectId
from bson.errors import InvalidId
import uuid
import datetime
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp
def is_library_item(item):
"""
Ermittelt zuverlässig, ob ein Objekt zur Bibliothek gehört.
Gibt True zurück, wenn ItemType ein Medientyp ist (Buch, Schulbuch, CD, DVD etc.)
ODER wenn is_library explizit True ist.
"""
if not item:
return False
# 1. Prüfe zuerst den Medientyp (ItemType)
item_type = str(item.get('ItemType', '') or '').strip().lower()
if item_type and item_type not in ['other', 'general', 'none', 'null']:
return True
# 2. Falls ItemType 'other' ist, prüfe das is_library Flag
return bool(item.get('is_library', False))
def safe_decrypt_user(encrypted_user):
"""
Safely decrypt an encrypted username string.
@@ -250,7 +268,10 @@ def get_group_item_ids(id):
def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3,
ansch_jahr, ansch_kost, code_4, reservierbar, isbn=None, item_type='general'):
ansch_jahr, ansch_kost, code_4, reservierbar, isbn="", item_type='other', library_category=""):
"""
Aktualisiert ein Objekt in MongoDB und setzt is_library korrekt basierend auf dem Medientyp.
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
@@ -258,29 +279,35 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter
old_item = items.find_one({'_id': ObjectId(id)})
if not old_item:
client.close()
return False
series_group_id = old_item.get('SeriesGroupId')
# is_library automatisch anhand des neuen item_type bestimmen
is_lib = is_library_item({'ItemType': item_type})
shared_update = {
'Name': name,
'Ort': ort,
'Beschreibung': beschreibung,
'Images': images,
'Filter': filter1,
'Filter2': filter2,
'Filter3': filter3,
'Images': images if isinstance(images, list) else [],
'Filter': filter1 if isinstance(filter1, list) else [],
'Filter2': filter2 if isinstance(filter2, list) else [],
'Filter3': filter3 if isinstance(filter3, list) else [],
'Anschaffungsjahr': ansch_jahr,
'Anschaffungskosten': ansch_kost,
'Reservierbar': reservierbar,
'ISBN': isbn,
'Reservierbar': bool(reservierbar),
'ISBN': str(isbn) if isbn else '',
'ItemType': item_type,
'Verfuegbar': verfuegbar,
'is_library': is_lib,
'library_category': library_category,
'Verfuegbar': bool(verfuegbar),
'LastUpdated': datetime.datetime.now()
}
specific_update = shared_update.copy()
specific_update['Code_4'] = code_4
specific_update['Code_4'] = str(code_4) if code_4 else ''
items.update_one({'_id': ObjectId(id)}, {'$set': specific_update})
@@ -1147,4 +1174,122 @@ def get_current_status(item_id, decrypt=True):
return None
except Exception as e:
print(f"Error retrieving current status for item {item_id}: {e}")
return None
return None
def sync_group_codes(primary_obj_id, base_code, individual_codes_list):
"""
Synchronisiert die Barcodes einer Gruppe im korrekten Schema
(angelehnt an das 'Augenmodell groß'-Vorbild).
"""
if not base_code:
return False
# Alle Ziel-Codes zusammenführen (Basis-Code an erster Stelle)
all_target_codes = [base_code]
for c in individual_codes_list:
if c and c not in all_target_codes:
all_target_codes.append(c)
item_count = len(all_target_codes)
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
primary_item = items.find_one({'_id': ObjectId(primary_obj_id)})
if not primary_item:
client.close()
return False
group_id = primary_item.get('SeriesGroupId')
# Wenn es mehr als 1 Item gibt und noch keine Gruppe existiert -> Neue GroupID erzeugen
if not group_id and item_count > 1:
group_id = str(uuid.uuid4())
# Wenn es nun eine Gruppe gibt (item_count > 1)
if item_count > 1:
# 1. Haupt-Item (Parent) aktualisieren
items.update_one(
{'_id': primary_item['_id']},
{'$set': {
'Code_4': base_code,
'SeriesGroupId': group_id,
'SeriesCount': item_count,
'SeriesPosition': 1,
'IsGroupedSubItem': False,
'ParentItemId': None
}}
)
# Bestehende Gruppenmitglieder laden
existing_items = list(items.find({'SeriesGroupId': group_id}))
existing_map = {it.get('Code_4'): it for it in existing_items if
it.get('Code_4') and str(it['_id']) != str(primary_item['_id'])}
# Alle verbleibenden Sub-Codes ab Position 2 abarbeiten
processed_sub_ids = []
for idx, code in enumerate(all_target_codes[1:], start=2):
if code in existing_map:
# Existiert bereits in der Gruppe -> Nur Position und Count aktualisieren
sub_item = existing_map[code]
processed_sub_ids.append(sub_item['_id'])
items.update_one(
{'_id': sub_item['_id']},
{'$set': {
'SeriesCount': item_count,
'SeriesPosition': idx,
'IsGroupedSubItem': True,
'ParentItemId': str(primary_item['_id'])
}}
)
else:
# Neu hinzukommender Code -> Als Klon (Sub-Item) erstellen
new_sub = primary_item.copy()
if '_id' in new_sub:
del new_sub['_id']
new_sub.update({
'Code_4': code,
'SeriesGroupId': group_id,
'SeriesCount': item_count,
'SeriesPosition': idx,
'IsGroupedSubItem': True,
'ParentItemId': str(primary_item['_id']),
'LastUpdated': primary_item.get('LastUpdated')
})
inserted_res = items.insert_one(new_sub)
processed_sub_ids.append(inserted_res.inserted_id)
# Nicht mehr benötigte Sub-Items aus dieser Gruppe entfernen
for code, sub_item in existing_map.items():
if sub_item['_id'] not in processed_sub_ids:
items.delete_one({'_id': sub_item['_id']})
else:
# Fall: Nur 1 einziges Item (keine Gruppe / Gruppe aufgelöst)
# Eventuelle alte Sub-Items dieser Gruppe löschen
if group_id:
items.delete_many({
'SeriesGroupId': group_id,
'_id': {'$ne': primary_item['_id']}
})
items.update_one(
{'_id': primary_item['_id']},
{'$set': {
'Code_4': base_code,
'SeriesGroupId': None,
'SeriesCount': 1,
'SeriesPosition': 1,
'IsGroupedSubItem': False,
'ParentItemId': None
}}
)
client.close()
return True
except Exception as e:
print(f"Error syncing group codes: {e}")
return False
+149 -35
View File
@@ -54,10 +54,34 @@ def _clean_name_fragment(value):
return cleaned
def _get_tenant_db(client):
def _get_tenant_db(client, tenant_id=None):
"""Return the current tenant database for the request, or fall back to default."""
try:
from tenant import get_tenant_db
if tenant_id is None:
return get_tenant_db(client)
tenant_id = str(tenant_id).strip()
if tenant_id:
try:
from tenant import get_tenant_config
config = get_tenant_config(tenant_id)
if isinstance(config, dict):
explicit_db = config.get('db') or config.get('db_name')
if explicit_db:
db_name = str(explicit_db).strip()
if db_name and not db_name.startswith('inventar_'):
db_name = f'inventar_{db_name}'
if db_name:
return client[db_name]
except Exception:
pass
sanitized = ''.join(c for c in tenant_id.lower() if c.isalnum() or c == '_')
if sanitized:
return client[f'inventar_{sanitized}']
return get_tenant_db(client)
except Exception:
return client[cfg.MONGODB_DB]
@@ -187,6 +211,7 @@ PERMISSION_PRESETS = {
},
'pages': {
'home': True,
'home_admin': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
@@ -345,10 +370,10 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': update_data})
result = users.update_one({'Username': username}, {'$set': update_data})
if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': update_data})
result = users.update_one({'username': username}, {'$set': update_data})
client.close()
return result.matched_count > 0
@@ -360,7 +385,7 @@ def get_favorites(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user:
return []
@@ -375,7 +400,7 @@ def add_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$or': [{'Username': username}, {'username': username}]},
{'$addToSet': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -390,7 +415,7 @@ def remove_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$or': [{'Username': username}, {'username': username}]},
{'$pull': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -472,15 +497,50 @@ def check_nm_pwd(username, password):
db = client[db_name]
users = db['users']
query = {'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]}
query = {'$or': [{'Username': username}, {'username': username}]}
user_record = users.find_one(query)
if user_record is None:
query = {'$or': [{'Username': username}, {'username': username}]}
user_record_fallback = users.find_one(query)
if user_record_fallback is None:
logger.warning("Kein Benutzer für %r in DB %r gefunden.", dp.encrypt_text(username), db_name)
return None
if db_name != cfg.MONGODB_DB:
default_users = client[cfg.MONGODB_DB]['users']
user_record_fallback = default_users.find_one(
{'$or': [
{'Username': username},
{'username': username},
]}
)
if user_record_fallback is not None:
migrated_record = dict(user_record_fallback)
migrated_record.pop('_id', None)
users.replace_one(
{'$or': [
{'Username': migrated_record.get('Username')},
{'username': migrated_record.get('username')},
]},
migrated_record,
upsert=True,
)
logger.warning(
"Tenant user %r migrated from default DB %r to tenant DB %r.",
dp.encrypt_text(username),
cfg.MONGODB_DB,
db_name,
)
user_record = users.find_one(
{'$or': [
{'Username': migrated_record.get('Username')},
{'username': migrated_record.get('username')},
]}
) or migrated_record
else:
logger.warning("Kein Benutzer für %r in DB %r gefunden.", dp.encrypt_text(username), db_name)
return None
else:
logger.warning("Kein Benutzer für %r in DB %r gefunden.", dp.encrypt_text(username), db_name)
return None
else:
user_record = user_record_fallback
@@ -497,18 +557,16 @@ def check_nm_pwd(username, password):
finally:
client.close()
def add_user(
def add_admin(
username,
password,
name='',
last_name='',
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset='standard_user',
permission_preset='full_access',
action_permissions=None,
page_permissions=None,
tenant_id=None,
):
"""
Add a new user to the database.
@@ -518,7 +576,7 @@ def add_user(
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client)
db = _get_tenant_db(client, tenant_id)
users = db['users']
permission_defaults = build_default_permission_payload(permission_preset)
@@ -535,7 +593,63 @@ def add_user(
safe_last_name = last_name.strip() if last_name else ''
user_doc = {
'Username': dp.encrypt_text(username),
'Username': username,
'Password': hashing(password),
'Admin': True,
'active_ausleihung': None,
'name': dp.encrypt_text(safe_name) if safe_name else '',
'last_name': dp.encrypt_text(safe_last_name) if safe_last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
'PagePermissions': permission_defaults['pages'],
}
users.insert_one(user_doc)
return True
finally:
client.close()
def add_user(
username,
password,
name='',
last_name='',
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset='standard_user',
action_permissions=None,
page_permissions=None,
tenant_id=None,
):
"""
Add a new user to the database.
"""
if not check_password_strength(password):
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client, tenant_id)
users = db['users']
permission_defaults = build_default_permission_payload(permission_preset)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
permission_defaults['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value)
safe_name = name.strip() if name else ''
safe_last_name = last_name.strip() if last_name else ''
user_doc = {
'Username': username,
'Password': hashing(password),
'Admin': (permission_preset == "full_access"),
'active_ausleihung': None,
@@ -596,9 +710,9 @@ def make_admin(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': True}})
result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': True}})
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
client.close()
return result.matched_count > 0
@@ -608,9 +722,9 @@ def remove_admin(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': False}})
result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': False}})
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
client.close()
return result.matched_count > 0
@@ -622,7 +736,7 @@ def get_user(username):
def find_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'username': username}) or users.find_one({'Username': username})
return users.find_one({'Username': username}) or users.find_one({'username': username}) or users.find_one({'username': username}) or users.find_one({'Username': username})
tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db:
@@ -658,12 +772,12 @@ def update_active_ausleihung(username, id_item, ausleihung):
users = db['users']
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
if result.matched_count == 0:
users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
client.close()
@@ -676,7 +790,7 @@ def get_active_ausleihung(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user:
@@ -691,7 +805,7 @@ def has_active_borrowing(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'Username': dp.encrypt_text(username)})
user = users.find_one({'username': username}) or users.find_one({'Username': username})
client.close()
if not user:
@@ -708,9 +822,9 @@ def delete_user(username):
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'username': dp.encrypt_text(username)})
result = users.delete_one({'username': username})
if result.deleted_count == 0:
result = users.delete_one({'Username': dp.encrypt_text(username)})
result = users.delete_one({'Username': username})
client.close()
return result.deleted_count > 0
@@ -725,9 +839,9 @@ def update_active_borrowing(username, item_id, status):
update_data = {'$set': {'active_borrowing': status, 'borrowed_item': item_id if status else None}}
result = users.update_one({'username': dp.encrypt_text(username)}, update_data)
result = users.update_one({'username': username}, update_data)
if result.matched_count == 0:
result = users.update_one({'Username': dp.encrypt_text(username)}, update_data)
result = users.update_one({'Username': username}, update_data)
client.close()
return result.modified_count > 0
@@ -741,7 +855,7 @@ def get_name(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user or not user.get("name"):
@@ -756,7 +870,7 @@ def get_last_name(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user or not user.get("last_name"):
@@ -791,12 +905,12 @@ def update_password(username, new_password):
hashed_password = hashing(new_password)
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'Password': hashed_password}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$set': {'Password': hashed_password}}
)
@@ -818,12 +932,12 @@ def update_user_name(username, name, last_name):
safe_last_name = dp.encrypt_text(last_name.strip()) if last_name else ''
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'name': safe_name, 'last_name': safe_last_name}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$set': {'name': safe_name, 'last_name': safe_last_name}}
)
-288
View File
@@ -1,288 +0,0 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!-- Edit Item Functions -->
<script>
// Function to check if a file is a video
function isVideoFile(filename) {
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return videoExtensions.includes(extension);
}
// Load location options for edit modal
function loadLocationOptions() {
fetch('/get_predefined_locations')
.then(response => response.json())
.then(data => {
const ortSelect = document.getElementById('edit-location');
if (ortSelect) {
// Clear existing options except the first one
while (ortSelect.children.length > 1) {
ortSelect.removeChild(ortSelect.lastChild);
}
// Add new options - data.locations contains the array
data.locations.forEach(location => {
const option = document.createElement('option');
option.value = location;
option.textContent = location;
ortSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error loading location options:', error);
});
}
// Edit modal functions
function openEditModal(itemId) {
if (typeof openEditModalFromServer === 'function') {
openEditModalFromServer(itemId);
return;
}
// Find the item data from allItems array
const item = allItems.find(i => i._id === itemId);
if (!item) {
console.error('Item not found:', itemId);
return;
}
// Populate the edit form with current item data
document.getElementById('edit-item-id').value = item._id;
document.getElementById('edit-name').value = item.Name || '';
document.getElementById('edit-description').value = item.Beschreibung || '';
document.getElementById('edit-code4').value = item.Code_4 || '';
document.getElementById('edit-year').value = item.Anschaffungsjahr || '';
document.getElementById('edit-cost').value = item.Anschaffungskosten || '';
// Set reservable status (default to true if undefined)
const reservierbarCheckbox = document.getElementById('edit-reservierbar');
if (reservierbarCheckbox) {
reservierbarCheckbox.checked = item.Reservierbar !== false;
}
// Load location options
loadLocationOptions();
// Set the current location
setTimeout(() => {
const locationSelect = document.getElementById('edit-location');
if (locationSelect && item.Ort) {
locationSelect.value = item.Ort;
}
}, 100);
// Handle filter arrays - set current values
const filter1Array = Array.isArray(item.Filter) ? item.Filter : (item.Filter ? [item.Filter] : []);
const filter2Array = Array.isArray(item.Filter2) ? item.Filter2 : (item.Filter2 ? [item.Filter2] : []);
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
// Set filter dropdowns (up to 4 each)
for (let i = 1; i <= 4; i++) {
// Filter 1
const filter1Select = document.getElementById(`edit-filter1-${i}`);
if (filter1Select) {
filter1Select.value = filter1Array[i-1] || '';
}
// Filter 2
const filter2Select = document.getElementById(`edit-filter2-${i}`);
if (filter2Select) {
filter2Select.value = filter2Array[i-1] || '';
}
// Filter 3
const filter3Select = document.getElementById(`edit-filter3-${i}`);
if (filter3Select) {
filter3Select.value = filter3Array[i-1] || '';
}
}
// Populate existing images
populateExistingImages(item.Images || []);
// Show the modal
document.getElementById('edit-modal').style.display = 'block';
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
// Function to add new location (for edit modal)
function addNewLocation(prefix) {
// Use different input IDs based on whether we're in edit mode
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
const selectId = prefix === 'edit' ? 'edit-location' : 'ort';
const newLocationInput = document.getElementById(inputId);
const newLocation = newLocationInput.value.trim();
if (!newLocation) {
alert('Bitte geben Sie einen Ort ein.');
return;
}
// Add to dropdown
const ortSelect = document.getElementById(selectId);
const option = document.createElement('option');
option.value = newLocation;
option.textContent = newLocation;
ortSelect.appendChild(option);
ortSelect.value = newLocation;
// Hide the input field
document.getElementById(prefix + '-new-location-container').style.display = 'none';
newLocationInput.value = '';
// Save to server
fetch('/add_location_value', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'value=' + encodeURIComponent(newLocation)
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.warn('Failed to save location to server:', data.error);
}
})
.catch(error => {
console.error('Error saving location:', error);
});
}
// Function to cancel adding a new location
function cancelAddLocation(prefix) {
const containerId = prefix === 'edit' ? 'edit-new-location-container' : 'new-location-container';
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
document.getElementById(containerId).style.display = 'none';
document.getElementById(inputId).value = '';
}
// Function to populate existing images in edit modal
function populateExistingImages(images) {
const previewContainer = document.getElementById('edit-image-preview-container');
if (!previewContainer) return;
previewContainer.innerHTML = '';
if (!images || images.length === 0) {
previewContainer.innerHTML = '<p>Keine Bilder vorhanden</p>';
return;
}
images.forEach((imageName, index) => {
const preview = document.createElement('div');
preview.className = 'image-preview-item';
const isVideo = isVideoFile(imageName);
const mediaHtml = isVideo
? `<video src="/uploads/${imageName}" style="max-width: 150px; max-height: 150px; object-fit: cover;" controls preload="metadata"></video>`
: `<img src="/uploads/${imageName}" alt="Image ${index + 1}" style="max-width: 150px; max-height: 150px; object-fit: cover;">`;
preview.innerHTML = `
${mediaHtml}
<div class="image-controls">
<button type="button" onclick="removeExistingImage('${imageName}', this)" style="background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; margin-left: 10px;">Entfernen</button>
</div>
`;
previewContainer.appendChild(preview);
// Add hidden input for the image
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = imageName;
previewContainer.appendChild(hiddenInput);
});
}
// Function to remove an existing image
function removeExistingImage(imageName, button) {
try {
// First, determine context - are we in edit mode or main view?
const inEditMode = !!document.getElementById('edit-item-form');
// Always remove the preview element (works in both contexts)
const previewItem = button.closest('.image-preview-item');
if (previewItem) {
previewItem.remove();
}
// If we're in edit mode, handle form inputs
if (inEditMode) {
// Find and remove the corresponding hidden input in the edit form
const editForm = document.getElementById('edit-item-form');
if (editForm) {
// Remove from existing images
const existingInputs = editForm.querySelectorAll('input[name="existing_images"]');
existingInputs.forEach(input => {
if (input.value === imageName) {
input.remove();
}
});
// Add to removed images
const removedInput = document.createElement('input');
removedInput.type = 'hidden';
removedInput.name = 'removed_images';
removedInput.value = imageName;
editForm.appendChild(removedInput);
}
} else {
// In main view, we may need different logic
console.log(`Image ${imageName} removed from display in main view`);
// Add any main view specific handling here
}
} catch (error) {
console.error(`Error in removeExistingImage: ${error.message}`);
}
}
// Validate file types for image uploads
document.addEventListener('DOMContentLoaded', function() {
// Check if we're in the edit item context
if (!document.getElementById('edit-item-form')) {
console.log('Edit item form not found, skipping edit item functions initialization');
return; // Exit early if we're not in the edit item context
}
const imageInput = document.getElementById('edit-new-images');
const previewContainer = document.getElementById('edit-image-preview-container');
if (imageInput) {
imageInput.addEventListener('change', function(e) {
// Validate file types before preview
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska',
'video/webm', 'video/x-flv', 'video/mp4', 'video/3gpp'];
const files = this.files;
let hasInvalidFile = false;
for (let i = 0; i < files.length; i++) {
if (!allowedTypes.includes(files[i].type)) {
hasInvalidFile = true;
// Clear the file input to prevent submission
this.value = '';
alert('Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP');
return; // Stop processing
}
}
// Continue with regular preview handling...
});
}
});
</script>
+509
View File
@@ -0,0 +1,509 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA.
-->
{% extends "base.html" %}
{% block title %}{{ page_title|default('Artikel bearbeiten') }} - Inventarsystem{% endblock %}
{% block content %}
<style>
.edit-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: var(--ui-surface);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.edit-form h1 {
color: var(--ui-text);
margin-bottom: 30px;
text-align: center;
font-size: 2em;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: var(--ui-text);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
.form-group textarea {
height: 100px;
resize: vertical;
}
.filter-inputs {
background-color: var(--ui-surface-soft);
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.filter-inputs h3 {
color: var(--ui-text);
margin-bottom: 15px;
font-size: 1.2em;
}
.multi-filter {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.filter-dropdown-select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
}
.isbn-input-group {
display: flex;
gap: 10px;
align-items: center;
}
.isbn-input-group input {
flex: 1;
}
.fetch-isbn-button {
background-color: #007bff;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s ease;
}
.fetch-isbn-button:hover {
background-color: #0056b3;
}
/* Scanner Elements */
#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;
}
/* Image Management */
.existing-images-grid {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 10px;
}
.existing-image-card {
border: 1px solid #ddd;
border-radius: 5px;
padding: 8px;
text-align: center;
background: #fff;
width: 120px;
}
.existing-image-card img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 4px;
}
.existing-image-card label {
font-size: 0.8em;
margin-top: 5px;
cursor: pointer;
font-weight: normal;
}
.add-new-btn {
background-color: #007bff;
color: white;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
margin-top: 5px;
font-size: 12px;
}
.submit-button {
background-color: #28a745;
color: white;
border: none;
padding: 15px 30px;
border-radius: 5px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
width: 100%;
margin-top: 20px;
transition: background-color 0.2s ease;
}
.submit-button:hover {
background-color: #218838;
}
.edit-new-location-container {
display: none;
margin-top: 10px;
}
</style>
<div class="edit-container">
<div class="edit-form">
<h1>{{ page_title|default('Artikel bearbeiten') }}</h1>
<form method="POST" action="{{ url_for('item_edit', id=item._id) }}" enctype="multipart/form-data">
<input type="hidden" name="item_id" value="{{ item._id }}">
{% if show_library_features %}
<!-- ================= LIBRARY SPECIFIC FIELDS ================= -->
<div class="form-group">
<label for="isbn">ISBN / Barcode:</label>
<div class="isbn-input-group">
<input type="text" id="isbn" name="isbn" value="{{ item.ISBN|default('') }}" placeholder="ISBN oder Barcode eingeben...">
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('edit')">Informationen 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;"></small>
<div id="book-info-container"></div>
</div>
<div class="filter-inputs" style="margin-bottom: 20px;">
<h3>Medientyp</h3>
<div class="form-group">
<select name="item_type_input" id="item_type_input">
<option value="Buch" {% if item.ItemType == 'Buch' %}selected{% endif %}>Buch</option>
<option value="Schulbuch" {% if item.ItemType == 'Schulbuch' %}selected{% endif %}>Schulbuch</option>
<option value="CD" {% if item.ItemType == 'CD' %}selected{% endif %}>CD</option>
<option value="DVD" {% if item.ItemType == 'DVD' %}selected{% endif %}>DVD</option>
<option value="Sonstiges" {% if item.ItemType == 'Sonstiges' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
<h3>Bibliotheks-Kategorie:</h3>
<div class="form-group">
<input type="text" name="library_category" id="library_category" value="{{ item.library_category|default('') }}" placeholder="z.B. Belletristik, Sachbücher, etc.">
</div>
</div>
{% endif %}
<!-- ================= COMMON CORE FIELDS ================= -->
<div class="form-group">
<label for="name">Name / Titel:</label>
<input type="text" id="name" name="name" value="{{ item.Name|default('') }}" required>
</div>
<div class="form-group">
<label for="ort">Ort / Standort:</label>
<select id="ort" name="ort" data-selected="{{ item.Ort|default('') }}" required>
<option value="">-- Bitte Ort auswählen --</option>
{% if item.Ort %}
<option value="{{ item.Ort }}" selected>{{ item.Ort }}</option>
{% endif %}
</select>
<button type="button" class="add-new-btn" id="add-new-location-btn">Neuen Ort hinzufügen</button>
<div id="new-location-container" class="edit-new-location-container">
<input type="text" id="new-location-input" placeholder="Neuen Ort eingeben">
<button type="button" onclick="addNewLocation()">Hinzufügen</button>
<button type="button" onclick="cancelAddLocation()">Abbrechen</button>
</div>
</div>
<div class="form-group">
<label for="beschreibung">Beschreibung:</label>
<textarea id="beschreibung" name="beschreibung" required>{{ item.Beschreibung|default('') }}</textarea>
</div>
<div class="form-group" id="primary_code_group">
<label for="code_4">Basis-Code (Haupt-Barcode)</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="code_4" name="code_4" class="form-control" value="{{ item.Code_4|default('') }}" required>
<button type="button" id="scan-code4-btn" class="fetch-isbn-button">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" id="individual_codes_group">
<label for="individual_codes">Weitere Einzelcodes der Gruppe (je Zeile ein Code)</label>
<textarea id="individual_codes" name="individual_codes" rows="4" class="form-control" placeholder="z.B.&#10;ABC-001&#10;ABC-002">{{ item.IndividualCodes|default('') }}</textarea>
<small style="display:block; color:#666; margin-top: 5px;">Der Basis-Code steht oben. Alle weiteren Gruppenmitglieder werden hier untereinander aufgeführt.</small>
</div>
{% if not show_library_features %}
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
<div class="filter-inputs">
<h3>Unterrichtsfach (Filter 1):</h3>
<div class="multi-filter">
{% for idx in range(4) %}
<div class="form-group">
<label for="filter1-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter1-{{ idx + 1 }}" name="filter" class="filter-dropdown-select" data-selected="{{ item.Filter[idx] if item.Filter and item.Filter|length > idx else '' }}">
<option value="">-- Optional --</option>
</select>
</div>
{% endfor %}
</div>
<h3>Jahrgangsstufe (Filter 2):</h3>
<div class="multi-filter">
{% for idx in range(4) %}
<div class="form-group">
<label for="filter2-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter2-{{ idx + 1 }}" name="filter2" class="filter-dropdown-select" data-selected="{{ item.Filter2[idx] if item.Filter2 and item.Filter2|length > idx else '' }}">
<option value="">-- Optional --</option>
</select>
</div>
{% endfor %}
</div>
<h3>Schlagwort (Filter 3):</h3>
<div class="multi-filter">
{% for idx in range(4) %}
<div class="form-group">
<label for="filter3-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<input type="text" id="filter3-{{ idx + 1 }}" name="filter3" value="{{ item.Filter3[idx] if item.Filter3 and item.Filter3|length > idx else '' }}">
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- ================= DATES & FINANCIALS ================= -->
<div class="form-group">
<label for="anschaffungsjahr">Anschaffungsjahr:</label>
<input type="date" id="anschaffungsjahr" name="anschaffungsjahr" value="{{ item.Anschaffungsjahr|default('') }}">
</div>
<div class="form-group">
<label for="anschaffungskosten">Anschaffungskosten (€):</label>
<input type="text" id="anschaffungskosten" name="anschaffungskosten" value="{{ item.Anschaffungskosten|default('') }}">
</div>
{% if not show_library_features %}
<!-- ================= INVENTORY IMAGE MANAGEMENT ================= -->
<div class="form-group">
<label>Bestehende Bilder behalten:</label>
{% if item.Images and item.Images|length > 0 %}
<div class="existing-images-grid">
{% for img in item.Images %}
<div class="existing-image-card">
<img src="{{ url_for('uploaded_file', filename=img) }}" alt="Bild">
<label>
<input type="checkbox" name="existing_images" value="{{ img }}" checked>
Behalten
</label>
</div>
{% endfor %}
</div>
{% else %}
<p style="color:#777; font-size:0.9em;">Keine Bilder vorhanden.</p>
{% endif %}
<label for="images" style="margin-top:15px;">Neue Bilder/Videos hinzufügen:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif, .mp4, .mov, .avi, .mkv, .webm" multiple>
</div>
{% endif %}
<div class="form-group">
<label for="reservierbar" style="display:inline-block; width:auto; margin-right:10px;">Reservierbar:</label>
<input type="checkbox" id="reservierbar" name="reservierbar" style="width:auto;" {% if item.Reservierbar %}checked{% endif %}>
</div>
<button type="submit" class="submit-button">Änderungen speichern</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script>
let scannerRunning = false;
let activeScannerCallback = null;
let code4LastScanned = '';
let code4LastScannedAt = 0;
function loadAndSelectFilterValues(filterNumber) {
fetch(`/get_predefined_filter_values/${filterNumber}`)
.then(res => res.json())
.then(data => {
for (let i = 1; i <= 4; i++) {
const select = document.getElementById(`filter${filterNumber}-${i}`);
if (!select) continue;
const selectedValue = select.getAttribute('data-selected') || '';
data.values.forEach(val => {
if (!val || String(val).trim() === '') return;
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
select.appendChild(opt);
});
if (selectedValue && !Array.from(select.options).some(o => o.value === selectedValue)) {
const customOpt = document.createElement('option');
customOpt.value = selectedValue;
customOpt.textContent = selectedValue;
select.appendChild(customOpt);
}
select.value = selectedValue;
}
})
.catch(err => console.error(`Error loading Filter ${filterNumber}:`, err));
}
function loadLocationOptions() {
fetch('/get_predefined_locations')
.then(res => res.json())
.then(data => {
const select = document.getElementById('ort');
if (!select) return;
const currentVal = select.getAttribute('data-selected') || select.value;
data.locations.forEach(loc => {
if (!Array.from(select.options).some(o => o.value === loc)) {
const opt = document.createElement('option');
opt.value = loc;
opt.textContent = loc;
select.appendChild(opt);
}
});
select.value = currentVal;
})
.catch(err => console.error('Error loading locations:', err));
}
function addNewLocation() {
const input = document.getElementById('new-location-input');
const val = input.value.trim();
if (!val) return;
const select = document.getElementById('ort');
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
opt.selected = true;
select.appendChild(opt);
document.getElementById('new-location-container').style.display = 'none';
input.value = '';
}
function cancelAddLocation() {
document.getElementById('new-location-container').style.display = 'none';
document.getElementById('new-location-input').value = '';
}
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" } },
decoder: { readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"] }
}, function(err) {
if (err) { errorStatusSetter("Kamera-Fehler.", true); return; }
Quagga.start(); scannerRunning = true; errorStatusSetter(completionMsg, false);
});
}
function killScannerHardware() {
if (!scannerRunning) return;
Quagga.stop(); scannerRunning = false; activeScannerCallback = null;
}
Quagga.onDetected(function(data) {
if (!data || !data.codeResult || !data.codeResult.code) return;
if (typeof activeScannerCallback === "function") activeScannerCallback(String(data.codeResult.code).trim());
});
function startCode4Scanner() {
const scannerBox = document.getElementById('code4-scanner');
const scanBtn = document.getElementById('scan-code4-btn');
const baseField = document.getElementById('code_4');
const indArea = document.getElementById('individual_codes');
if (scannerBox.style.display !== 'none') {
killScannerHardware(); scannerBox.style.display = 'none'; scanBtn.textContent = 'Barcode scannen'; return;
}
scannerBox.style.display = 'block'; scanBtn.textContent = 'Scanner stoppen';
runEngineInitialization('#code4-scanner', function(decodedText) {
const now = Date.now();
if (decodedText === code4LastScanned && (now - code4LastScannedAt) < 1500) return;
code4LastScanned = decodedText; code4LastScannedAt = now;
killScannerHardware(); scannerBox.style.display = 'none'; scanBtn.textContent = 'Barcode scannen';
if (!baseField.value.trim()) {
baseField.value = decodedText;
} else {
let codes = indArea.value.split('\n').map(c => c.trim()).filter(c => c);
if (!codes.includes(decodedText) && baseField.value.trim() !== decodedText) {
codes.push(decodedText);
indArea.value = codes.join('\n');
}
}
}, 'Scanner läuft...', msg => { document.getElementById('code4-scan-status').textContent = msg; });
}
document.addEventListener('DOMContentLoaded', function() {
loadLocationOptions();
// Load Filter options if elements exist on page
if (document.getElementById('filter1-1')) {
loadAndSelectFilterValues(1);
loadAndSelectFilterValues(2);
}
const scanCodeBtn = document.getElementById('scan-code4-btn');
if (scanCodeBtn) scanCodeBtn.addEventListener('click', startCode4Scanner);
const addLocBtn = document.getElementById('add-new-location-btn');
if (addLocBtn) addLocBtn.addEventListener('click', () => {
const c = document.getElementById('new-location-container');
c.style.display = c.style.display === 'none' ? 'block' : 'none';
});
});
</script>
{% endblock %}
-752
View File
@@ -1,752 +0,0 @@
{% extends "base.html" %}
{% block title %}Bibliothek - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="content-container">
<!-- Header Section -->
<div class="header-section">
<div class="header-title-group">
<h1>📚 Bibliothek</h1>
<p class="subtitle">Bücher, CDs und weitere Medien</p>
</div>
</div>
<!-- Search & Filter Section -->
<div class="search-filter-section">
<input type="text" id="searchInput" placeholder="Nach Titel, Autor, ISBN suchen..." class="search-input">
<div class="filter-group" id="filterGroup">
<button class="filter-btn" id="filterBtn" aria-expanded="false" aria-label="Filter öffnen">
🔍 Filter <span class="filter-arrow"></span>
</button>
<div class="filter-dropdown" id="filterDropdown" style="display: none;">
<div class="filter-section">
<h4>Medientyp</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="book"> Buch</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="cd"> CD/DVD</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="other"> Sonstige</label>
</div>
</div>
<div class="filter-section">
<h4>Status</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="available"> Verfügbar</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="borrowed"> Ausgeliehen</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="reserved"> Reserviert</label>
</div>
</div>
<button id="clearFiltersBtn" class="button">Filter zurücksetzen</button>
</div>
</div>
<!-- Barcode Scanner -->
<button id="scannerBtn" class="scan-button" aria-expanded="false" aria-label="Scanner öffnen">
📱 Scanner <span class="scanner-arrow"></span>
</button>
</div>
<!-- Scanner Panel -->
<div id="qrContainer" class="qr-container" style="display: none;">
<div id="qr-reader" style="width: auto; height: 300px;"></div>
<span id="qr-result" style="display: none;"></span>
</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>
<div class="table-cell author-cell">Autor/Künstler</div>
<div class="table-cell isbn-cell">ISBN/Code</div>
<div class="table-cell type-cell">Typ</div>
<div class="table-cell status-cell">Status</div>
<div class="table-cell actions-cell">Aktionen</div>
</div>
</div>
<!-- Items Container -->
<div id="itemsContainer" class="items-container">
{% if library_items %}
{% for item in library_items %}
<div class="item-card library-item" data-item-id="{{ item._id }}" data-type="{{ item.get('ItemType', 'book') }}" data-status="{{ 'borrowed' if item.get('Verfuegbar') == False else 'available' }}">
<!-- Card Mode View -->
<div class="item-content card-mode">
<div class="item-image-wrapper">
{% if item.get('Image') %}
<img src="/uploads/{{ item.Image }}" alt="{{ item.Name }}" class="item-image">
{% else %}
<div class="placeholder-image">📚</div>
{% endif %}
</div>
<div class="item-details">
<h3 class="item-name">{{ item.Name }}</h3>
{% if item.get('Author') %}
<p class="item-author">Von: {{ item.Author }}</p>
{% endif %}
{% if item.get('ISBN') %}
<p class="item-isbn">ISBN: {{ item.ISBN }}</p>
{% endif %}
<div class="item-meta">
<span class="item-type">{{ item.get('ItemType', 'Medium') }}</span>
<span class="item-status {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
</div>
</div>
<!-- Table Mode View -->
<div class="item-content table-mode" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">
<span class="descriptor">Titel:</span>
<span class="value">{{ item.Name }}</span>
</div>
<div class="table-cell author-cell">
<span class="descriptor">Autor:</span>
<span class="value">{{ item.get('Author', '—') }}</span>
</div>
<div class="table-cell isbn-cell">
<span class="descriptor">ISBN:</span>
<span class="value">{{ item.get('ISBN', '—') }}</span>
</div>
<div class="table-cell type-cell">
<span class="descriptor">Typ:</span>
<span class="value">{{ item.get('ItemType', 'Medium') }}</span>
</div>
<div class="table-cell status-cell">
<span class="descriptor">Status:</span>
<span class="value {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
<div class="table-cell actions-cell">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
</div>
</div>
</div>
<!-- Action Buttons (Card Mode) -->
<div class="item-actions card-mode" style="display: flex;">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
{% if item.get('Verfuegbar') == True %}
<button class="action-button borrow-btn" data-item-id="{{ item._id }}">Ausleihen</button>
{% else %}
<button class="action-button return-btn" data-item-id="{{ item._id }}" disabled>Ausgeliehen</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Bibliotheks-Medien gefunden</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Detail Modal -->
<div id="detailModal" class="modal" style="display: none;">
<div class="modal-content">
<button class="modal-close" aria-label="Schließen"></button>
<div class="modal-body" id="modalBody">
<!-- Filled by JavaScript -->
</div>
</div>
</div>
<!-- Borrow Modal -->
<div id="borrowModal" class="modal" style="display: none;">
<div class="modal-content">
<h2>Ausleihen</h2>
<form id="borrowForm">
<input type="hidden" id="borrowItemId" name="item_id">
<div class="form-group">
<label for="studentIdInput">Schülerausweis-ID (optional):</label>
<input type="text" id="studentIdInput" name="student_id" placeholder="Ausweis scannen oder eingeben" class="form-input">
</div>
<div class="form-group">
<label for="borrowDaysInput">Ausleih-Dauer (Tage):</label>
<input type="number" id="borrowDaysInput" name="borrow_days" min="1" max="365" class="form-input" value="14">
</div>
<div class="form-actions">
<button type="submit" class="button primary-button">Ausleihen</button>
<button type="button" class="button cancel-button" onclick="document.getElementById('borrowModal').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
}
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const virtualizationState = window.mobileLibraryVirtualizationState || null;
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
const cardActions = item.querySelector('.item-actions.card-mode');
const tableContent = item.querySelector('.item-content.table-mode');
if (mode === 'table') {
if (cardContent) cardContent.style.display = 'none';
if (cardActions) cardActions.style.display = 'none';
if (tableContent) tableContent.style.display = 'block';
} else {
if (cardContent) cardContent.style.display = 'flex';
if (cardActions) cardActions.style.display = 'flex';
if (tableContent) tableContent.style.display = 'none';
}
});
tableHeader.style.display = mode === 'table' ? 'block' : 'none';
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer');
if (!container) {
return;
}
if (!window.mobileLibraryVirtualizationState) {
window.mobileLibraryVirtualizationState = {
active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
}
const state = window.mobileLibraryVirtualizationState;
function restoreAllImages() {
state.items.forEach(item => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
const originalSrc = image.dataset.mobileSrc || '';
if (!image.getAttribute('src') && originalSrc) {
image.setAttribute('src', originalSrc);
}
});
}
function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4;
state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || '';
}
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) {
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
image.setAttribute('src', image.dataset.mobileSrc);
}
return;
}
if (image.getAttribute('src')) {
image.removeAttribute('src');
}
});
}
function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) {
endIndex = Math.min(state.items.length, startIndex + 14);
}
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return;
}
state.lastStart = startIndex;
state.lastEnd = endIndex;
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
state.topSpacer.nextSibling.remove();
}
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(state.items[index]);
}
container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0;
let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) {
const height = state.items[index].getBoundingClientRect().height;
if (height > 0) {
measuredHeightSum += height;
measuredCount += 1;
}
}
if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount;
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
}
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex);
}
function scheduleWindowRender() {
if (!state.active || state.framePending) {
return;
}
state.framePending = true;
requestAnimationFrame(() => {
state.framePending = false;
renderVisibleWindow();
});
}
function activateVirtualization() {
if (state.active) {
return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(savedMode);
document.getElementById('viewModeToggle').addEventListener('click', function() {
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
const newMode = currentMode === 'card' ? 'table' : 'card';
setViewMode(newMode);
});
// Details button handlers
document.querySelectorAll('.details-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const itemId = this.dataset.itemId;
fetch(`/get_item_details/${itemId}`)
.then(r => r.json())
.then(data => {
if (data.success) {
displayLibraryItemDetail(data.item);
document.getElementById('detailModal').style.display = 'flex';
}
});
});
});
// Borrow button handlers
document.querySelectorAll('.borrow-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.getElementById('borrowItemId').value = this.dataset.itemId;
document.getElementById('borrowModal').style.display = 'flex';
});
});
// Borrow form submit
document.getElementById('borrowForm').addEventListener('submit', function(e) {
e.preventDefault();
const itemId = document.getElementById('borrowItemId').value;
const studentId = document.getElementById('studentIdInput').value || null;
const borrowDays = document.getElementById('borrowDaysInput').value || 14;
fetch('/borrow_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
item_id: itemId,
student_id: studentId,
borrow_days: borrowDays
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich ausgeliehen!');
location.reload();
} else {
alert('Fehler: ' + data.message);
}
});
});
// Modal close handlers
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
});
initMobileWindowedLibraryLoading();
});
function displayLibraryItemDetail(item) {
const modalBody = document.getElementById('modalBody');
const returnBtn = item.Verfuegbar === false ? `<button class="action-button return-btn" onclick="returnItem('${item._id}')">Zurückgeben</button>` : '';
const borrowBtn = item.Verfuegbar === true ? `<button class="action-button borrow-btn" onclick="openBorrowModal('${item._id}')">Ausleihen</button>` : '';
modalBody.innerHTML = `
<h2>${item.Name}</h2>
<div class="detail-content">
${item.Image ? `<img src="/uploads/${item.Image}" alt="${item.Name}" style="max-width: 200px; margin-bottom: 20px;">` : ''}
<p><strong>Autor:</strong> ${item.Author || '—'}</p>
<p><strong>ISBN:</strong> ${item.ISBN || '—'}</p>
<p><strong>Typ:</strong> ${item.ItemType || 'Medium'}</p>
<p><strong>Status:</strong> ${item.Verfuegbar === false ? 'Ausgeliehen' : 'Verfügbar'}</p>
${item.User ? `<p><strong>Ausgeliehen von:</strong> ${item.User}</p>` : ''}
${item.Beschreibung ? `<p><strong>Beschreibung:</strong> ${item.Beschreibung}</p>` : ''}
<div class="modal-actions">
${borrowBtn}
${returnBtn}
</div>
</div>
`;
}
function openBorrowModal(itemId) {
document.getElementById('borrowItemId').value = itemId;
document.getElementById('borrowModal').style.display = 'flex';
document.getElementById('detailModal').style.display = 'none';
}
function returnItem(itemId) {
if (confirm('Wirklich zurückgeben?')) {
fetch('/return_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_id: itemId})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich zurückgegeben!');
location.reload();
}
});
}
}
// Filter dropdown toggle with auto-close
function closeAllFilters() {
document.querySelectorAll('.filter-dropdown').forEach(d => d.style.display = 'none');
document.getElementById('filterBtn').setAttribute('aria-expanded', 'false');
document.getElementById('filterBtn').classList.remove('open');
}
document.getElementById('filterBtn').addEventListener('click', function(e) {
e.stopPropagation();
const dropdown = document.getElementById('filterDropdown');
const isOpen = dropdown.style.display !== 'none';
closeAllFilters();
if (!isOpen) {
dropdown.style.display = 'block';
this.setAttribute('aria-expanded', 'true');
this.classList.add('open');
}
});
document.addEventListener('click', closeAllFilters);
document.getElementById('filterDropdown').addEventListener('click', e => e.stopPropagation());
// Clear filters
document.getElementById('clearFiltersBtn').addEventListener('click', function() {
document.querySelectorAll('.filter-checkbox').forEach(c => c.checked = false);
closeAllFilters();
});
// Scanner toggle
// 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';
Quagga.stop();
isScanning = false;
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;
});
}
});
// 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);
});
</script>
<style>
.library-item .item-image-wrapper {
width: 120px;
height: 160px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.library-item .placeholder-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
}
.library-item .item-author {
font-size: 0.9rem;
color: #666;
margin: 4px 0;
}
.library-item .item-isbn {
font-size: 0.85rem;
color: #999;
font-family: monospace;
margin: 4px 0;
}
.library-item .item-type {
display: inline-block;
background: #e8f0fe;
color: #1967d2;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.library-item .item-status.available {
background: #e6f4ea;
color: #137333;
}
.library-item .item-status.borrowed {
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 %}
File diff suppressed because it is too large Load Diff
+73 -577
View File
@@ -819,7 +819,7 @@
flex: 0 0 auto;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button, .damage-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button, .damage-button {
padding: 10px 18px;
border: none;
border-radius: 5px;
@@ -942,27 +942,6 @@
box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3);
}
/* Duplicate Button */
.duplicate-button {
background-color: #6f42c1;
color: white;
border: 2px solid #6f42c1;
}
.duplicate-button:hover {
background-color: #5a32a3;
border-color: #4e2a8e;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(111, 66, 193, 0.3);
color: white;
text-decoration: none;
}
.duplicate-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(111, 66, 193, 0.3);
}
.damage-button {
background-color: #fd7e14;
color: white;
@@ -1645,7 +1624,7 @@
gap: 10px !important;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
width: 100% !important;
min-height: 44px !important;
padding: 12px 20px !important;
@@ -1777,7 +1756,7 @@
justify-content: flex-start;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 10px 20px;
font-size: 0.9rem;
min-width: 110px;
@@ -1790,7 +1769,7 @@
gap: 8px;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 8px 16px;
font-size: 0.85rem;
min-width: 95px;
@@ -1808,7 +1787,7 @@
gap: 15px;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 12px 24px;
font-size: 1rem;
min-width: 120px;
@@ -1951,11 +1930,6 @@
display: none;
}
/* Edit new location container */
.edit-new-location-container {
display: none;
margin-top: 10px;
}
/* Modal dialog styling */
.modal-dialog-white {
@@ -1969,11 +1943,6 @@
margin-top: 10px;
}
/* Element text colors for better visibility */
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-title) !important;
}
/* Standardized button styles across the application */
.search-button, .scan-button, .filter-toggle, .clear-filter,
.add-new-btn, .popup-close-button, .prev-image-button, .next-image-button,
@@ -1990,7 +1959,7 @@
}
/* Standardize form and document buttons */
.save-button, .cancel-button, .remove-book-cover-button, .remove-duplicate-image-button,
.save-button, .cancel-button, .remove-book-cover-button,
.import-book-button, .fetch-isbn-button, .nav-back-button {
display: inline-flex;
align-items: center;
@@ -2474,187 +2443,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div>
</div>
</div>
<!-- Add the edit modal form -->
{% if current_permissions.actions.get('can_edit', False) %}
<div id="edit-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeEditModal()">&times;</span>
<h2>Objekt bearbeiten</h2>
<form id="edit-item-form" method="POST" enctype="multipart/form-data">
<input type="hidden" id="edit-item-id" name="id">
<div class="form-row">
<div class="form-group">
<label for="edit-name">Name:</label>
<input type="text" id="edit-name" name="name" required>
</div>
<div class="form-group">
<label for="edit-location">Ort:</label>
<select id="edit-location" name="ort" required>
<option value="">-- Bitte Ort auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
<button type="button" class="add-new-btn" id="edit-add-new-location-btn">
Neuen Ort hinzufügen
</button>
<div id="edit-new-location-container" class="edit-new-location-container">
<input type="text" id="edit-new-location-input" placeholder="Neuen Ort eingeben">
<button type="button" onclick="addNewLocation('edit')">Hinzufügen</button>
<button type="button" onclick="cancelAddLocation('edit')">Abbrechen</button>
</div>
</div>
</div>
<div class="form-group">
<label for="edit-description">Beschreibung:</label>
<textarea id="edit-description" name="beschreibung" required></textarea>
</div>
<!-- Updated filter inputs for edit form with dropdowns -->
<div class="filter-inputs">
<h3>Unterrichtsfach:</h3>
<div class="multi-filter">
<div class="form-group">
<label for="edit-filter1-1">Wert 1:</label>
<select id="edit-filter1-1" name="filter" class="filter-dropdown-select">
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter1-2">Wert 2:</label>
<select id="edit-filter1-2" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter1-3">Wert 3:</label>
<select id="edit-filter1-3" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter1-4">Wert 4:</label>
<select id="edit-filter1-4" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>Jahrgangsstufe:</h3>
<div class="multi-filter">
<div class="form-group">
<label for="edit-filter2-1">Wert 1:</label>
<select id="edit-filter2-1" name="filter2" class="filter-dropdown-select">
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter2-2">Wert 2:</label>
<select id="edit-filter2-2" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter2-3">Wert 3:</label>
<select id="edit-filter2-3" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter2-4">Wert 4:</label>
<select id="edit-filter2-4" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>Schlagwort:</h3>
<div class="multi-filter">
<div class="form-group">
<label for="edit-filter3-1">Wert 1:</label>
<input type="text" id="edit-filter3-1" name="filter3">
</div>
<div class="form-group">
<label for="edit-filter3-2">Wert 2:</label>
<input type="text" id="edit-filter3-2" name="filter3">
</div>
<div class="form-group">
<label for="edit-filter3-3">Wert 3:</label>
<input type="text" id="edit-filter3-3" name="filter3">
</div>
<div class="form-group">
<label for="edit-filter3-4">Wert 4:</label>
<input type="text" id="edit-filter3-4" name="filter3">
</div>
</div>
</div>
<div class="form-group">
<label for="edit-year">Anschaffungsjahr:</label>
<input type="date" id="edit-year" name="anschaffungsjahr">
</div>
<div class="form-group">
<label for="edit-cost">Anschaffungskosten (€):</label>
<input id="edit-cost" name="anschaffungskosten">
</div>
<div class="form-group">
<label for="edit-code4">Code:</label>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<input id="edit-code4" name="code_4">
<button type="button" class="fetch-isbn-button" id="scan-edit-code-btn">Barcode scannen</button>
</div>
</div>
<div class="form-group">
<label for="edit-reservierbar" style="display:inline-block; width:auto; margin-right:10px;">Reservierbar:</label>
<input type="checkbox" id="edit-reservierbar" name="reservierbar" style="width:auto;">
<small style="display:block; color:#666;">Wenn deaktiviert, kann der Artikel nicht im Voraus reserviert werden.</small>
</div>
<!-- New section for managing images -->
<div class="form-group">
<label>Vorhandene Bilder:</label>
<div id="edit-existing-images" class="existing-images-container">
<!-- Existing images will be added here dynamically -->
</div>
</div>
<div class="form-group">
<label for="edit-new-images">
<span>Neue Bilder/Videos hinzufügen:</span>
<span>(Bilder/Videos werden vom Original übernommen)</span>
</label>
<input type="file" id="edit-new-images" name="new_images" accept=".jpg, .jpeg, .png, .gif, .mp4, .mov, .avi, .mkv, .webm, .flv, .m4v, .3gp" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>
<!-- Add image preview container -->
<div class="image-preview-container" id="edit-image-preview-container"></div>
</div>
<div class="form-group">
<label for="edit-isbn">ISBN:</label>
<div class="isbn-input-group">
<input type="text" id="edit-isbn" name="isbn" placeholder="ISBN eingeben...">
<button type="button" class="fetch-isbn-button" id="scan-edit-isbn-btn">ISBN scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('edit')">Buchinformationen abrufen</button>
</div>
<div id="edit-book-info-container" class="book-info-container"></div>
</div>
<div class="form-actions">
<button type="submit" class="save-button">Speichern</button>
<button type="button" class="cancel-button" onclick="closeEditModal()">Abbrechen</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<!-- Schedule Appointment Modal -->
@@ -2889,58 +2677,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
});
function scanIntoEditCode() {
const qrReader = document.getElementById('qr-reader');
const editCodeInput = document.getElementById('edit-code4');
const scanEditBtn = document.getElementById('scan-edit-code-btn');
if (!qrReader || !editCodeInput || !scanEditBtn) {
return;
}
// Toggle close if it's already running
if (isScanning && qrReader.style.display !== 'none') {
stopScanner();
scanEditBtn.textContent = 'Barcode scannen';
return;
}
scanEditBtn.textContent = 'Scanner schließen';
// 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';
});
}
function scanIntoEditIsbn() {
const qrReader = document.getElementById('qr-reader');
const editIsbnInput = document.getElementById('edit-isbn');
const scanIsbnBtn = document.getElementById('scan-edit-isbn-btn');
if (!qrReader || !editIsbnInput || !scanIsbnBtn) {
return;
}
// Toggle close if it's already running
if (isScanning && qrReader.style.display !== 'none') {
stopScanner();
scanIsbnBtn.textContent = 'ISBN scannen';
return;
}
scanIsbnBtn.textContent = 'Scanner schließen';
// 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');
}
});
}
function rebuildFilter3Options() {
if (!allItems) return;
@@ -3324,21 +3060,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
loadPredefinedFilterValues(1);
loadPredefinedFilterValues(2);
loadPredefinedFilterValues(3);
// Set up edit form submission
setupEditFormSubmission();
// Set up schedule form submission
setupScheduleFormSubmission();
// Set up add new location buttons
const editAddLocationBtn = document.getElementById('edit-add-new-location-btn');
if (editAddLocationBtn) {
editAddLocationBtn.addEventListener('click', function() {
const container = document.getElementById('edit-new-location-container');
container.style.display = container.style.display === 'none' ? 'block' : 'none';
});
}
// Find and attach event listener to all logout links
const logoutLinks = document.querySelectorAll('a[href*="logout"]');
@@ -3391,35 +3117,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Close modals when clicking outside
window.onclick = function(event) {
const itemModal = document.getElementById('item-modal');
const editModal = document.getElementById('edit-modal');
if (event.target === itemModal) {
itemModal.style.display = 'none';
}
if (event.target === editModal) {
editModal.style.display = 'none';
}
};
// Set up code validation for edit form
const editCodeField = document.getElementById('edit-code4');
if (editCodeField) {
editCodeField.addEventListener('blur', function() {
const itemIdField = document.getElementById('edit-item-id');
const excludeId = itemIdField ? itemIdField.value : null;
validateCodeField(this, excludeId);
});
}
const scanEditCodeBtn = document.getElementById('scan-edit-code-btn');
if (scanEditCodeBtn) {
scanEditCodeBtn.addEventListener('click', scanIntoEditCode);
}
const scanEditIsbnBtn = document.getElementById('scan-edit-isbn-btn');
if (scanEditIsbnBtn) {
scanEditIsbnBtn.addEventListener('click', scanIntoEditIsbn);
}
});
// Function to load items from server
@@ -3742,9 +3444,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
{% if current_permissions.actions.get('can_edit', False) %}
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
{% endif %}
{% if current_permissions.actions.get('can_insert', False) %}
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
{% endif %}
@@ -4171,162 +3870,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function openEditModalForSelectedUnit(defaultItemId, selectId) {
let targetItemId = defaultItemId;
try {
const selectedUnit = selectId ? document.getElementById(selectId) : null;
if (selectedUnit && selectedUnit.value) {
targetItemId = selectedUnit.value;
}
} catch (e) {
// Keep default item id as fallback.
}
openEditModalFromServer(targetItemId);
}
function closeEditModal() {
const editModal = document.getElementById('edit-modal');
if (editModal) {
editModal.style.display = 'none';
}
}
function openEditModalFromServer(itemId) {
const editModal = document.getElementById('edit-modal');
if (!editModal) {
console.error('Edit modal nicht gefunden');
return;
}
console.log('DEBUG: openEditModal called with itemId:', itemId);
// Fetch the item data from the backend
fetch(`/get_item/${itemId}`)
.then(response => {
console.log('DEBUG: Response status:', response.status);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
console.log('DEBUG: Fetched data:', data);
// Backend returns the item directly or wrapped in error/success
const item = data.error ? null : (data.item || data);
console.log('DEBUG: Parsed item:', item);
if (!item || !item._id) {
console.error('DEBUG: Item not found or invalid');
alert('Item nicht gefunden');
return;
}
// Fill in the form fields with the item data
document.getElementById('edit-item-id').value = item._id || '';
document.getElementById('edit-name').value = item.Name || '';
document.getElementById('edit-location').value = item.Ort || '';
document.getElementById('edit-description').value = item.Beschreibung || '';
document.getElementById('edit-year').value = item.Anschaffungsjahr || '';
document.getElementById('edit-cost').value = item.Anschaffungskosten || '';
document.getElementById('edit-code4').value = item.Code_4 || '';
document.getElementById('edit-isbn').value = item.ISBN || '';
document.getElementById('edit-reservierbar').checked = item.Reservierbar !== false;
// Fill in filter 1 (Unterrichtsfach)
const filter1Array = Array.isArray(item.Filter) ? item.Filter : (item.Filter ? [item.Filter] : []);
document.getElementById('edit-filter1-1').value = filter1Array[0] || '';
document.getElementById('edit-filter1-2').value = filter1Array[1] || '';
document.getElementById('edit-filter1-3').value = filter1Array[2] || '';
document.getElementById('edit-filter1-4').value = filter1Array[3] || '';
// Fill in filter 2 (Jahrgangsstufe)
const filter2Array = Array.isArray(item.Filter2) ? item.Filter2 : (item.Filter2 ? [item.Filter2] : []);
document.getElementById('edit-filter2-1').value = filter2Array[0] || '';
document.getElementById('edit-filter2-2').value = filter2Array[1] || '';
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
// 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] || '';
document.getElementById('edit-filter3-3').value = filter3Array[2] || '';
document.getElementById('edit-filter3-4').value = filter3Array[3] || '';
// Display existing images
const existingImagesContainer = document.getElementById('edit-existing-images');
const editForm = document.getElementById('edit-item-form');
existingImagesContainer.innerHTML = '';
if (editForm) {
editForm.querySelectorAll('input[name="existing_images"], input[name="removed_images"]').forEach(input => input.remove());
}
if (item.Images && Array.isArray(item.Images)) {
item.Images.forEach((image, index) => {
const isVideo = isVideoFile(image);
const imageDiv = document.createElement('div');
imageDiv.className = 'existing-image-item';
imageDiv.style.marginBottom = '10px';
const thumbnailInfo = item.ThumbnailInfo && item.ThumbnailInfo[index];
const imageSrc = thumbnailInfo && thumbnailInfo.has_preview ?
thumbnailInfo.preview_url :
(image.startsWith('/uploads/') || image.startsWith('http') ?
image :
`{{ url_for('uploaded_file', filename='') }}${image}`);
const row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '8px';
row.style.alignItems = 'center';
if (isVideo) {
const video = document.createElement('video');
video.src = imageSrc;
video.style.maxWidth = '100px';
video.style.maxHeight = '100px';
video.style.objectFit = 'contain';
video.controls = true;
row.appendChild(video);
} else {
const img = document.createElement('img');
img.src = imageSrc;
img.style.maxWidth = '100px';
img.style.maxHeight = '100px';
img.style.objectFit = 'contain';
img.alt = `Existierendes Bild ${index + 1}`;
row.appendChild(img);
}
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-image-button';
deleteButton.textContent = 'Löschen';
deleteButton.addEventListener('click', () => removeExistingImage(image, deleteButton));
row.appendChild(deleteButton);
imageDiv.appendChild(row);
existingImagesContainer.appendChild(imageDiv);
if (editForm) {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = image;
editForm.appendChild(hiddenInput);
}
});
}
// Display the modal
editModal.style.display = 'block';
})
.catch(error => {
console.error('Fehler beim Laden des Items:', error);
alert('Fehler beim Laden des Items');
});
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => {
const map = {
@@ -4677,7 +4220,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
: `<button class="ausleihen disabled-button" disabled>Ausgeliehen</button>`
}
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports && damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
@@ -4686,7 +4228,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
modal.style.display = 'block';
const closeButton = modal.querySelector('.close-modal');
@@ -4923,6 +4467,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function openEditModalForSelectedUnit(itemId, selectId) {
const select = document.getElementById(selectId);
const targetId = (select && select.value) ? select.value : itemId;
// Leitet auf die Bearbeiten-Seite weiter und übergibt die aktuelle URL für den Redirect nach dem Speichern
window.location.href = `/item_edit/${targetId}`;
}
function changeModalImage(direction) {
const currentIndex = window.currentModalImageIndex;
const total = window.totalModalImages;
@@ -5349,8 +4900,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
// Load location options for edit modal
// Edit-related functions moved to edit_item_functions.html
// Schedule modal functions
function openScheduleModal(itemId) {
@@ -5470,99 +5019,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
// Setup edit form submission
function setupEditFormSubmission() {
const editForm = document.getElementById('edit-item-form');
if (editForm) {
editForm.addEventListener('submit', function(e) {
e.preventDefault();
const itemId = document.getElementById('edit-item-id').value;
const formData = new FormData(this);
fetch(`/edit_item/${itemId}`, {
method: 'POST',
body: formData
})
.then(response => {
if (response.ok) {
closeEditModal();
// Reload items to show updated information
loadItems();
// Show success message
const successMsg = document.createElement('div');
successMsg.className = 'alert alert-success';
successMsg.textContent = 'Item wurde erfolgreich aktualisiert!';
successMsg.style.position = 'fixed';
successMsg.style.top = '20px';
successMsg.style.right = '20px';
successMsg.style.zIndex = '9999';
document.body.appendChild(successMsg);
setTimeout(() => {
if (successMsg.parentNode) {
successMsg.parentNode.removeChild(successMsg);
}
}, 3000);
} else {
alert('Fehler beim Aktualisieren des Items');
}
})
.catch(error => {
console.error('Error updating item:', error);
alert('Fehler beim Aktualisieren des Items');
});
});
}
}
// Duplication function
function duplicateItem(itemId) {
// Show loading indicator
const loadingDiv = document.createElement('div');
loadingDiv.innerHTML = `
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 10000; display: flex;
align-items: center; justify-content: center;">
<div class="modal-dialog-white">
<div>Element wird dupliziert...</div>
<div class="modal-content-margin">
<div class="spinner"></div>
</div>
</div>
</div>
`;
document.body.appendChild(loadingDiv);
// Create form data for the duplicate_item request
const formData = new FormData();
formData.append('original_item_id', itemId);
// Send duplication request to get item data
fetch('/duplicate_item', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
document.body.removeChild(loadingDiv);
if (data.success) {
// Store duplication data in sessionStorage for the upload page
sessionStorage.setItem('duplicateItemData', JSON.stringify(data.item_data));
// Redirect to upload admin page
window.location.href = '/upload_admin?duplicate=true';
} else {
alert('Fehler beim Duplizieren: ' + (data.message || 'Unbekannter Fehler'));
}
})
.catch(error => {
document.body.removeChild(loadingDiv);
console.error('Error duplicating item:', error);
alert('Fehler beim Duplizieren des Elements. Bitte versuchen Sie es erneut.');
});
}
// Helper functions for appointment display
function formatAppointmentDate(dateString) {
if (!dateString) return '';
@@ -5632,10 +5088,62 @@ document.addEventListener('DOMContentLoaded', ()=>{
futureAppointments.sort((a, b) => new Date(a.date) - new Date(b.date));
return futureAppointments[0];
}
</script>
<!-- Include edit item functions -->
{% include "edit_item_functions.html" %}
// Load location options
function loadLocationOptions() {
fetch('/get_predefined_locations')
.then(response => response.json())
.then(data => {
const ortSelect = document.getElementById('ort');
if (ortSelect) {
// Clear existing options except the first one
while (ortSelect.children.length > 1) {
ortSelect.removeChild(ortSelect.lastChild);
}
// Add new options - data.locations contains the array
data.locations.forEach(location => {
const option = document.createElement('option');
option.value = location;
option.textContent = location;
ortSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error loading location options:', error);
});
}
// Function to add new location
function addNewLocation() {
const newLocationInput = document.getElementById('new-location-input');
const newLocation = newLocationInput.value.trim();
if (!newLocation) {
alert('Bitte geben Sie einen Ort ein.');
return;
}
// Add to dropdown
const ortSelect = document.getElementById('ort');
const option = document.createElement('option');
option.value = newLocation;
option.textContent = newLocation;
option.selected = true;
ortSelect.appendChild(option);
// Hide the input container
document.getElementById('new-location-container').style.display = 'none';
newLocationInput.value = '';
}
// Function to cancel adding new location
function cancelAddLocation() {
document.getElementById('new-location-container').style.display = 'none';
document.getElementById('new-location-input').value = '';
}
</script>
<!-- Include reset item functions -->
{% include "reset_item_functions.html" %}
@@ -5864,16 +5372,4 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
+2 -50
View File
@@ -19,7 +19,7 @@
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Fach/Kategorie') }} (Filter 1)</h2>
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Jahrgang') }} (Filter 1)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
@@ -67,7 +67,7 @@
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'System/Bereich') }} (Filter 2)</h2>
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'Fach') }} (Filter 2)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
@@ -110,54 +110,6 @@
</div>
</div>
</div>
<!-- Filter 3 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">{{ filter_names.get('3', 'Typ/Art') }} (Filter 3)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=3) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter3_values %}
<div class="list-group">
{% for value in filter3_values %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-3-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=3, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-3-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=3, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-3-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="alert alert-warning">
+463 -335
View File
@@ -6,7 +6,7 @@
<div class="container">
<div class="header-section">
<h1>Neuen Benutzer registrieren</h1>
<p class="subtitle">Erstellen Sie ein neues Benutzerkonto und legen Sie Zugriffsrechte fest</p>
<p class="subtitle">Erstellen Sie ein neues Benutzerkonto oder importieren Sie mehrere Benutzer per CSV</p>
</div>
<div class="flash-container">
@@ -23,7 +23,43 @@
</div>
<div class="content">
<!-- 1. CSV BULK IMPORT CARD -->
<div class="form-card" style="margin-bottom: 2rem; border-top: 4px solid #059669;">
<div class="card-header" style="margin-bottom: 1rem;">
<h2>Massenregistrierung via CSV</h2>
<p class="subtitle" style="color: #4b5563;">
Laden Sie eine CSV-Datei hoch (Format: <code>Vorname, Nachname</code>).
Benutzernamen und sichere Passwörter werden serverseitig generiert. Nach dem Upload erhalten Sie direkt ein PDF mit Zugangsdaten (2 pro Seite zum Ausschneiden).
</p>
</div>
<form method="POST" action="{{ url_for('register_csv') }}" enctype="multipart/form-data">
<div class="form-group">
<label for="permission-preset-csv">Berechtigungs-Preset für alle CSV-Benutzer</label>
<select id="permission-preset-csv" name="permission_preset" class="form-select" style="margin-bottom: 1rem;">
{% for preset_key, preset in permission_presets.items() %}
<option value="{{ preset_key }}">{{ preset.label }}</option>
{% endfor %}
</select>
<label for="csv_file">CSV-Datei auswählen</label>
<div class="input-container">
<span class="input-icon">📄</span>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required style="padding: 10px;">
</div>
</div>
<div class="form-group form-actions" style="margin-top: 1.5rem;">
<button type="submit" class="action-button" style="background-color: #059669; color: white;">
📥 CSV Importieren & Zugangsdaten-PDF Herunterladen
</button>
</div>
</form>
</div>
<!-- 2. SINGLE USER REGISTRATION CARD -->
<div class="form-card">
<h2>Einzelnen Benutzer registrieren</h2>
<form method="POST" action="{{ url_for('register') }}">
<div class="form-group">
@@ -32,11 +68,13 @@
<span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Vornamen ein" required onchange="generateUsername()" oninput="generateUsername()">
</div>
<label for="last-name">Nachname</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required onchange="generateUsername()" oninput="generateUsername()">
</div>
<label for="username">Benutzername <span style="color: #9ca3af;">(Vorschau - wird serverseitig finalisiert)</span></label>
<div class="input-container">
<span class="input-icon">👤</span>
@@ -44,7 +82,7 @@
</div>
<p class="anonymize-hint">Klarnamen werden nur zur Erzeugung des Benutzernamens als Kürzel (z.B. SimFri) verwendet; bei Kollision wird automatisch ein Buchstabe mehr genommen.</p>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<div class="password-rules" id="password-rules" aria-live="polite">
@@ -57,20 +95,21 @@
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul>
</div>
<div class="input-wrapper">
<div class="input-container">
<span class="input-icon">🔒</span>
<!-- HTML5 Pattern blockiert unsichere Passwörter vor dem Absenden -->
<input
id="password"
name="password"
placeholder="Geben Sie ein sicheres Passwort ein"
<input
type="password"
id="password"
name="password"
placeholder="Geben Sie ein sicheres Passwort ein"
required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}">
<button type="button" id="toggle-pw-btn" class="toggle-pw-btn" onclick="togglePasswordVisibility()" style="background:none; border:none; cursor:pointer; padding-right:10px;">👁️</button>
</div>
</div>
<div class="pw-actions">
<div class="pw-actions" style="margin-top: 8px;">
<button type="button" class="btn-secondary" onclick="generateSecurePassword()">Passwort generieren</button>
</div>
</div>
@@ -120,323 +159,6 @@
</div>
</div>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--error-color: #e74c3c;
--text-color: var(--ui-text);
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.1rem;
color: #777;
margin-top: 0;
}
.content {
background-color: var(--ui-surface);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.form-card {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--primary-dark);
}
.input-container {
position: relative;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #aaa;
font-size: 1.2rem;
}
input[type="text"],
input[type="password"],
.form-select {
width: 100%;
padding: 0.8rem 1rem 0.8rem 3rem;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 1rem;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
.form-select {
padding-left: 1rem;
}
input::placeholder {
color: #aaa;
}
.form-actions {
margin-top: 2rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 30px;
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: #27ae60;
}
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
}
.flash.success {
background-color: #d4edda;
color: #155724;
border-left: 4px solid var(--success-color);
}
.flash.error {
background-color: #f8d7da;
color: #721c24;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
margin: 1rem auto;
}
.content {
padding: 1.5rem;
}
.header-section h1 {
font-size: 2rem;
}
}
.password-rules {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: var(--ui-surface-soft);
}
.password-rules-title {
margin: 0 0 8px;
font-weight: 700;
color: #1f2937;
font-size: 0.92rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 22px;
margin: 5px 0;
color: #b91c1c;
font-size: 0.9rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.anonymize-hint {
margin-top: 10px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 10px 12px;
color: #0c4a6e;
font-size: 0.92rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
background: var(--ui-surface-soft);
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 1rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #1f2937;
}
/* Neue Styles für die Passwort-Erweiterungen */
.pw-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-secondary {
padding: 8px 12px;
border: 1px solid #d1d5db;
background-color: #f3f4f6;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #e5e7eb;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.input-wrapper .input-container {
flex-grow: 1;
margin: 0;
}
</style>
<script>
// Hilfsfunktion: Umlaute auflösen und Sonderzeichen entfernen
function cleanNameForUsername(text) {
@@ -462,7 +184,7 @@ function generateUsername() {
const firstName = cleanNameForUsername(document.getElementById('name').value);
const lastName = cleanNameForUsername(document.getElementById('last-name').value);
let username = '';
if (firstName && lastName) {
username = formatPart(firstName, 3) + formatPart(lastName, 3);
} else if (firstName) {
@@ -470,7 +192,7 @@ function generateUsername() {
} else if (lastName) {
username = formatPart(lastName, 6);
}
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.value = username || '';
@@ -503,11 +225,12 @@ function generateSecurePassword() {
const pwField = document.getElementById('password');
pwField.value = password;
// Automatisch sichtbar machen, damit der User es kopieren kann
// Automatisch sichtbar machen
pwField.type = 'text';
document.getElementById('toggle-pw-btn').textContent = '🙈';
const toggleBtn = document.getElementById('toggle-pw-btn');
if (toggleBtn) toggleBtn.textContent = '🙈';
updatePasswordRules();
}
@@ -517,10 +240,10 @@ function togglePasswordVisibility() {
const toggleBtn = document.getElementById('toggle-pw-btn');
if (pwField.type === "password") {
pwField.type = "text";
toggleBtn.textContent = '🙈';
if (toggleBtn) toggleBtn.textContent = '🙈';
} else {
pwField.type = "password";
toggleBtn.textContent = '👁️';
if (toggleBtn) toggleBtn.textContent = '👁️';
}
}
@@ -530,7 +253,7 @@ function updatePasswordRules() {
if (!passwordInput) return;
const value = String(passwordInput.value || '');
const setRuleState = (id, ok) => {
const node = document.getElementById(id);
if (node) node.classList.toggle('ok', !!ok);
@@ -592,4 +315,409 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
</script>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--success-dark: #27ae60;
--csv-color: #059669;
--csv-dark: #047857;
--error-color: #e74c3c;
--text-color: var(--ui-text, #1f2937);
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.05rem;
color: #6b7280;
margin-top: 0;
}
.content {
background-color: var(--ui-surface, #ffffff);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
/* Karten-Layout für Einzel- & Massenregistrierung */
.form-card {
max-width: 620px;
margin: 0 auto 2.5rem auto;
padding: 1.5rem;
background: var(--ui-surface-soft, #f9fafb);
border: 1px solid #e5e7eb;
border-radius: var(--border-radius);
}
.form-card:last-child {
margin-bottom: 0;
}
.form-card h2 {
font-size: 1.4rem;
margin-top: 0;
margin-bottom: 0.5rem;
color: #111827;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
font-size: 0.95rem;
color: #374151;
}
.input-container {
position: relative;
width: 100%;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
font-size: 1.1rem;
pointer-events: none;
z-index: 1;
}
/* Eingabefelder & Selects */
input[type="text"],
input[type="password"],
input[type="file"],
.form-select {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.8rem;
border: 1px solid #d1d5db;
border-radius: var(--border-radius);
font-size: 0.95rem;
background-color: #ffffff;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="file"]:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
.form-select {
padding-left: 1rem;
cursor: pointer;
}
/* Stylings für den CSV-Datei-Upload */
input[type="file"] {
padding-left: 2.8rem;
cursor: pointer;
}
input[type="file"]::file-selector-button {
padding: 0.35rem 0.75rem;
border-radius: 4px;
border: 1px solid #d1d5db;
background: #f3f4f6;
color: #374151;
cursor: pointer;
margin-right: 12px;
font-weight: 500;
transition: var(--transition);
}
input[type="file"]::file-selector-button:hover {
background: #e5e7eb;
}
input::placeholder {
color: #9ca3af;
}
code {
background: #e0f2fe;
color: #0369a1;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
/* Buttons */
.form-actions {
margin-top: 1.5rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: var(--success-dark);
}
/* Toggle-Button für Passwort-Auge */
.input-wrapper {
position: relative;
width: 100%;
}
.toggle-pw-btn {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 4px;
z-index: 2;
opacity: 0.7;
transition: opacity 0.2s;
}
.toggle-pw-btn:hover {
opacity: 1;
}
/* Passwort-Regeln Box */
.password-rules {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.password-rules-title {
margin: 0 0 6px;
font-weight: 700;
color: #1f2937;
font-size: 0.88rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 20px;
margin: 4px 0;
color: #dc2626;
font-size: 0.85rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.pw-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid #d1d5db;
background-color: #ffffff;
color: #374151;
border-radius: 6px;
cursor: pointer;
font-size: 0.88rem;
font-weight: 500;
transition: var(--transition);
}
.btn-secondary:hover {
background-color: #f3f4f6;
border-color: #9ca3af;
}
/* Hinweise & Rechte-Panels */
.anonymize-hint {
margin-top: 8px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
padding: 8px 12px;
color: #0369a1;
font-size: 0.88rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px;
background: #ffffff;
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 0.95rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0;
color: #374151;
font-size: 0.9rem;
cursor: pointer;
}
/* Flash Nachrichten */
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 0.9rem 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.4);
}
.flash.success {
background-color: #d1fae5;
color: #065f46;
border-left: 4px solid var(--csv-color);
}
.flash.error {
background-color: #fee2e2;
color: #991b1b;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 0.75rem;
margin: 1rem auto;
}
.content {
padding: 1rem;
}
.form-card {
padding: 1rem;
}
.header-section h1 {
font-size: 1.8rem;
}
}
</style>
{% endblock %}
@@ -11,7 +11,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schülerausweis - Barcode PDF Download</title>
<title>Bibliotheksausweis - Barcode PDF Download</title>
<style>
* {
margin: 0;
@@ -123,7 +123,7 @@
<body>
<div class="container">
<div class="icon">📇</div>
<h1>Schülerausweis-Download</h1>
<h1>Bibliotheksausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
<div class="info-box">
+2 -2
View File
@@ -229,7 +229,7 @@
<div class="container">
<div class="student-card-header">
<div>
<h1>📚 Bibliotheksausweise (Bibliotek)</h1>
<h1>📚 Bibliotheksausweise (Bibliothek)</h1>
</div>
<div class="export-buttons">
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
@@ -249,7 +249,7 @@
<!-- Add/Edit Form -->
<div class="student-card-form">
<h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Schülerausweis{% endif %}</h2>
<h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Bibliotheksausweis{% endif %}</h2>
<form method="POST" action="{{ url_for('student_cards_admin') }}">
{% if edit_mode %}
+3 -3
View File
@@ -280,7 +280,7 @@
<button type="button" data-target-step="0">1. Startseite</button>
<button type="button" data-target-step="1">2. {{ 'Artikel erfassen' if is_admin else 'Artikel finden' }}</button>
{% if library_module_enabled %}
<button type="button" data-target-step="2">3. Bibliotek</button>
<button type="button" data-target-step="2">3. Bibliothek</button>
{% endif %}
<button type="button" data-target-step="{{ '3' if library_module_enabled else '2' }}">4. Alltag</button>
</div>
@@ -341,7 +341,7 @@
{% if library_module_enabled %}
<article class="workflow-step" data-step-index="2" data-step-key="library">
<h3>Bibliotek-Bereich</h3>
<h3>Bibliothek-Bereich</h3>
<p>Dieser Bereich ist speziell für Bücher und Medienausleihe.</p>
<ul>
{% if is_admin %}
@@ -358,7 +358,7 @@
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('library_view') }}">Zur Bibliotek</a>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('library_view') }}">Zur Bibliothek</a>
{% if is_admin %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_loans_admin') }}">Ausleihen ansehen</a>
{% endif %}
+184 -197
View File
@@ -11,59 +11,6 @@
{% block title %}{{ page_title|default('Artikel hochladen') }} - Inventarsystem{% endblock %}
{% block content %}
{% if duplicate_data and duplicate_data.images %}
<!-- Hidden server-side values for duplication, will be processed by JavaScript -->
<div id="server-duplicate-data"
data-name="{{ duplicate_data.name|default('') }}"
data-description="{{ duplicate_data.description|default('') }}"
data-location="{{ duplicate_data.location|default('') }}"
data-room="{{ duplicate_data.room|default('') }}"
data-year="{{ duplicate_data.year|default('') }}"
data-cost="{{ duplicate_data.cost|default('') }}"
data-images="{{ duplicate_data.images|tojson|safe }}"
data-filter1="{{ duplicate_data.filter1|tojson|safe if duplicate_data.filter1 else '[]' }}"
data-filter2="{{ duplicate_data.filter2|tojson|safe if duplicate_data.filter2 else '[]' }}"
data-filter3="{{ duplicate_data.filter3|tojson|safe if duplicate_data.filter3 else '[]' }}"
data-original-id="{{ duplicate_data.original_id|default('') }}"
style="display:none;">
</div>
<script>
// Pre-initialize duplicate data from server
var serverDuplicateData = null;
const dataElement = document.getElementById('server-duplicate-data');
if (dataElement) {
// Safely parse JSON arrays with error handling
function safeJsonParse(jsonStr, defaultValue = []) {
try {
if (!jsonStr) return defaultValue;
return JSON.parse(jsonStr);
} catch (e) {
console.error("Error parsing JSON:", e, "Value was:", jsonStr);
return defaultValue;
}
}
// Get images data and log it for debugging
const imagesData = dataElement.getAttribute('data-images') || "[]";
console.log("Raw images data attribute:", imagesData);
serverDuplicateData = {
name: dataElement.getAttribute('data-name') || "",
description: dataElement.getAttribute('data-description') || "",
location: dataElement.getAttribute('data-location') || "",
room: dataElement.getAttribute('data-room') || "",
year: dataElement.getAttribute('data-year') || "",
cost: dataElement.getAttribute('data-cost') || "",
images: safeJsonParse(imagesData),
filter1: safeJsonParse(dataElement.getAttribute('data-filter1')),
filter2: safeJsonParse(dataElement.getAttribute('data-filter2')),
filter3: safeJsonParse(dataElement.getAttribute('data-filter3')),
original_id: dataElement.getAttribute('data-original-id') || ""
};
console.log("Server-provided duplicate data:", serverDuplicateData);
}
</script>
{% endif %}
<style>
/* Book information display styles */
@@ -296,38 +243,11 @@
color: #721c24 !important;
}
/* Duplicate code popup styling */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
animation: overlayFadeIn 0.3s ease;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.duplicate-code-popup {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
text-align: center;
position: relative;
animation: popupSlideIn 0.3s ease;
}
@keyframes popupSlideIn {
from {
opacity: 0;
@@ -761,6 +681,21 @@
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
<form method="POST" action="{{ url_for('upload_item') }}" enctype="multipart/form-data">
<input type="hidden" name="upload_mode" value="{{ upload_mode|default('item') }}">
{% if show_library_features %}
<div class="form-group">
<label for="isbn">ISBN/Barcode (für Bild-Suche):</label>
<div class="isbn-input-group">
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen 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. 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>
{% endif %}
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
@@ -784,7 +719,44 @@
<label for="beschreibung">Beschreibung:</label>
<textarea id="beschreibung" name="beschreibung" required></textarea>
</div>
<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 optional (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; margin-bottom: 10px;">Tipp: Der Präfix ist optional. 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" 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" 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.&#10;ABC-001&#10;ABC-002" class="form-control"></textarea>
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
</div>
{% if show_library_features %}
<!-- Library Mode: Single Customizable Filter -->
<div class="filter-inputs">
@@ -800,9 +772,9 @@
</select>
<small style="display:block; color:#666;">Wählen Sie einen Medientyp aus zur Klassifizierung.</small>
</div>
<h3>Kategorie/Typ:</h3>
<h3>Kategorie/Typ/Fach:</h3>
<div class="form-group">
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc. (optional)">
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
</div>
</div>
@@ -864,7 +836,7 @@
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="formupload_admin-group">
<div class="form-group">
<label for="filter2-4">Wert 4:</label>
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
@@ -902,71 +874,22 @@
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten">
</div>
<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; 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" 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" 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.&#10;ABC-001&#10;ABC-002" class="form-control"></textarea>
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
</div>
<!-- Image upload (hidden for library mode) -->
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
<label for="images">Bilder/Videos:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif, .mp4, .mov, .avi, .mkv, .webm, .flv, .m4v, .3gp" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>
<!-- Image upload -->
<div class="form-group">
<label for="images">Bilder:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF</div>
<!-- Add image preview area -->
<div class="image-preview-container" id="image-preview-container"></div>
</div>
<!-- ISBN fields (library page only) -->
{% if show_library_features %}
<div class="form-group">
<label for="isbn">ISBN/Barcode (für Bild-Suche):</label>
<div class="isbn-input-group">
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen 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. 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>
<div class="form-group">
<label for="reservierbar" style="display:inline-block; width:auto; margin-right:10px;">Reservierbar:</label>
<input type="checkbox" id="reservierbar" name="reservierbar" style="width:auto;">
<small style="display:block; color:#666;">Wenn deaktiviert, kann der Artikel nicht im Voraus reserviert werden (Sofort-Ausleihe bleibt möglich).</small>
</div>
{% endif %}
<button type="submit" class="submit-button">{{ 'Bücher hochladen' if show_library_features else 'Artikel hochladen' }}</button>
</form>
@@ -1023,6 +946,90 @@
<script>
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
function getFocusableFormFields(form) {
if (!form) return [];
return Array.from(form.querySelectorAll('input, select, textarea, button'))
.filter((element) => {
if (element.disabled || element.getAttribute('aria-hidden') === 'true') return false;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const tagName = element.tagName.toLowerCase();
const type = (element.type || '').toLowerCase();
return tagName !== 'button' || type !== 'button';
});
}
function focusNextFormField(currentField) {
const form = currentField && currentField.form ? currentField.form : null;
const fields = getFocusableFormFields(form || document);
const currentIndex = fields.indexOf(currentField);
if (currentIndex >= 0) {
for (let i = currentIndex + 1; i < fields.length; i++) {
const nextField = fields[i];
const nextStyle = window.getComputedStyle(nextField);
if (nextStyle.display !== 'none' && nextStyle.visibility !== 'hidden') {
nextField.focus();
if (nextField.tagName.toLowerCase() === 'select') {
nextField.click();
}
return true;
}
}
}
if (form) {
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.focus();
return true;
}
}
return false;
}
function handleIsbnLookupFromScanner() {
const isbnField = document.getElementById('isbn');
if (!isbnField) return false;
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(isbnField.value) : isbnField.value.trim();
if (!normalizedIsbn) return false;
if (typeof updateIsbnLiveValidation === 'function') {
updateIsbnLiveValidation();
}
if (typeof fetchBookInfo === 'function') {
fetchBookInfo('upload');
}
return true;
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form').forEach(function (form) {
form.addEventListener('keydown', function (event) {
const target = event.target;
if (event.key !== 'Enter') return;
if (!target || target.tagName === 'TEXTAREA' && !event.shiftKey) return;
const tagName = target.tagName.toLowerCase();
const type = (target.type || '').toLowerCase();
if (!['input', 'select', 'textarea'].includes(tagName)) return;
if (['button', 'submit', 'reset', 'file', 'checkbox', 'radio', 'hidden'].includes(type)) return;
event.preventDefault();
if (target.id === 'isbn' || target.name === 'isbn') {
handleIsbnLookupFromScanner();
return;
}
focusNextFormField(target);
});
});
});
// Function to check if a file is a video
function isVideoFile(filename) {
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
@@ -1194,7 +1201,7 @@
let generatedCodes = [];
for (let i = start; i <= end; i++) {
let numStr = i.toString().padStart(paddingLength, '0');
generatedCodes.push(`${prefix}${numStr}`);
generatedCodes.push(prefix ? `${prefix}${numStr}` : numStr);
}
const codeField = document.getElementById('code_4');
@@ -1384,10 +1391,32 @@
scanModeSelect.addEventListener('change', toggleScanMode);
}
// 4. ISBN Live-Validierung
// 4. ISBN Live-Validierung und automatische Abfrage nach Scan/Enter
const isbnInput = document.getElementById('isbn');
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
isbnInput.addEventListener('input', updateIsbnLiveValidation);
if (isbnInput) {
if (typeof updateIsbnLiveValidation === 'function') {
isbnInput.addEventListener('input', updateIsbnLiveValidation);
}
let isbnLookupTimer = null;
isbnInput.addEventListener('input', function () {
clearTimeout(isbnLookupTimer);
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(isbnInput.value) : isbnInput.value.trim();
if (normalizedIsbn) {
isbnLookupTimer = setTimeout(function () {
handleIsbnLookupFromScanner();
}, 250);
}
});
isbnInput.addEventListener('change', function () {
if (typeof normalizeIsbnClient === 'function') {
const normalizedIsbn = normalizeIsbnClient(isbnInput.value);
if (normalizedIsbn) {
handleIsbnLookupFromScanner();
}
}
});
}
});
// Load predefined filter values for dropdowns
@@ -1769,11 +1798,11 @@
if (existingPopup) {
existingPopup.remove();
}
// Create popup overlay
const overlay = document.createElement('div');
overlay.className = 'popup-overlay';
// Create popup content
overlay.innerHTML = `
<div class="duplicate-code-popup">
@@ -1786,9 +1815,9 @@
<button class="popup-close-button" onclick="this.closest('.popup-overlay').remove()">Verstanden</button>
</div>
`;
document.body.appendChild(overlay);
// Auto-remove after 10 seconds
setTimeout(() => {
if (overlay.parentNode) {
@@ -1828,28 +1857,25 @@
function setupImagePreview() {
const imageInput = document.getElementById('images');
const previewContainer = document.getElementById('image-preview-container');
if (imageInput && previewContainer) {
imageInput.addEventListener('change', function(e) {
previewContainer.innerHTML = '';
const files = e.target.files;
// Validate file types before preview
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska',
'video/webm', 'video/x-flv', 'video/mp4', 'video/3gpp'];
let hasInvalidFile = false;
// Validate file types before preview (Strictly Images)
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
for (let i = 0; i < files.length; i++) {
if (!allowedTypes.includes(files[i].type)) {
hasInvalidFile = true;
// Clear the file input to prevent submission
imageInput.value = '';
previewContainer.innerHTML = '<div class="error-message">Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>';
previewContainer.innerHTML = '<div class="error-message">Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF</div>';
return; // Stop processing
}
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
@@ -1864,17 +1890,6 @@
previewContainer.appendChild(preview);
};
reader.readAsDataURL(file);
} else if (file.type.startsWith('video/')) {
const preview = document.createElement('div');
preview.className = 'image-preview video-preview';
preview.innerHTML = `
<div class="video-placeholder">
<div class="video-icon">🎥</div>
<div class="video-name">${file.name}</div>
</div>
<button type="button" class="remove-image" onclick="removeImagePreview(this, ${i})">×</button>
`;
previewContainer.appendChild(preview);
}
}
});
@@ -1940,34 +1955,6 @@
}
});
}
// Handle duplication data if available (from sessionStorage or server)
if (typeof prefillFormWithDuplicateData === 'function') {
setTimeout(() => {
prefillFormWithDuplicateData();
}, 500); // Wait for all dropdowns to load
}
});
// Initialize duplication data - handle both server-side and sessionStorage data
let duplicateData = null;
// First check if serverDuplicateData is defined by the script above
if (typeof serverDuplicateData !== 'undefined' && serverDuplicateData) {
console.log("Using server-provided duplicateData:", serverDuplicateData);
duplicateData = serverDuplicateData;
// Ensure images is properly handled as an array
if (duplicateData.images) {
if (typeof duplicateData.images === 'string') {
try {
duplicateData.images = JSON.parse(duplicateData.images);
} catch (e) {
console.error("Error parsing duplicate images array:", e);
}
}
console.log("Parsed duplicate images successfully.");
}
}
</script>
{% endblock %}
+230 -185
View File
@@ -3,215 +3,260 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Batch Upload - CSV & Bilder</title>
<title>Batch Upload</title>
<!-- CSRF-Token für JavaScript bereitstellen -->
<meta name="csrf-token" content="{{ session.get('_csrf_token', '') }}">
<style>
:root {
--primary-color: #4a90e2;
--background-color: #f4f7f6;
--text-color: #333;
--border-radius: 8px;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.upload-container {
background: white;
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 500px;
}
h2 {
margin-top: 0;
color: var(--primary-color);
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
}
input[type="file"] {
display: block;
width: 100%;
padding: 0.5rem;
border: 1px dashed #ccc;
border-radius: var(--border-radius);
background: #fafafa;
cursor: pointer;
}
.btn-submit {
width: 100%;
padding: 0.75rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease;
}
.btn-submit:hover {
background-color: #357abd;
}
.btn-submit:disabled {
background-color: #a0c4e8;
cursor: not-allowed;
}
/* Status & Feedback Messages */
#status-message {
margin-top: 1rem;
padding: 1rem;
border-radius: var(--border-radius);
display: none;
text-align: center;
}
.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
background-color: #e2e3e5;
color: #383d41;
border: 1px solid #d6d8db;
}
.spinner {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
border: 3px solid rgba(0,0,0,0.1);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s ease-in-out infinite;
vertical-align: middle;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
body { font-family: sans-serif; padding: 20px; background: #f4f7f6; }
.upload-container { background: white; padding: 20px; border-radius: 8px; max-width: 500px; margin: 0 auto; }
.form-group { margin-bottom: 15px; }
label { display: block; font-weight: bold; margin-bottom: 5px; }
input[type="file"] { width: 100%; padding: 8px; box-sizing: border-box; }
.btn-submit { width: 100%; padding: 10px; background: #4a90e2; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; }
.btn-submit:disabled { background: #ccc; }
#uploadProgress { margin-top: 20px; display: none; }
progress { width: 100%; height: 20px; }
#logList { background: #fafafa; border: 1px solid #eee; padding: 10px; max-height: 150px; overflow-y: auto; font-size: 0.85em; list-style: none; }
</style>
</head>
<body>
<div class="upload-container">
<h2>Inventar Batch Upload</h2>
<div class="upload-container">
<h2>Inventar Batch Upload</h2>
<form id="uploadForm">
<div class="form-group">
<label for="csv_file">1. items.csv Datei auswählen</label>
<!-- Akzeptiert nur CSV Dateien -->
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
</div>
<form id="batchUploadForm">
<div class="form-group">
<label for="csv_file">1. items.csv auswählen</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
</div>
<div class="form-group">
<label for="images">2. Bilder auswählen</label>
<!-- multiple erlaubt das Auswählen mehrerer Bilder gleichzeitig -->
<input type="file" id="images" name="images" accept="image/*" multiple required>
<small style="color: #666; display: block; margin-top: 5px;">Du kannst mehrere Bilder markieren (Strg/Cmd gedrückt halten).</small>
</div>
<div class="form-group">
<label for="images">2. Bilder auswählen</label>
<input type="file" id="images" name="images" accept="image/*" multiple required>
</div>
<button type="submit" id="submitBtn" class="btn-submit">Daten hochladen</button>
</form>
<button type="submit" id="uploadBtn" class="btn-submit">Daten hochladen</button>
</form>
<div id="status-message"></div>
<div id="uploadProgress">
<div id="progressText">Bereite Upload vor...</div>
<progress id="progressBar" value="0" max="100"></progress>
<ul id="logList"></ul>
</div>
</div>
<script>
document.getElementById('uploadForm').addEventListener('submit', async function(e) {
e.preventDefault();
// 1. Robuster CSV-Parser, der Zeilenumbrüche und Kommas in Texten korrekt ignoriert
function parseCSV(csvString) {
const rows = [];
let currentRow = [];
let currentCell = '';
let insideQuotes = false;
const form = e.target;
const submitBtn = document.getElementById('submitBtn');
const statusDiv = document.getElementById('status-message');
for (let i = 0; i < csvString.length; i++) {
const char = csvString[i];
const nextChar = csvString[i + 1];
submitBtn.disabled = true;
submitBtn.innerText = 'Wird verarbeitet...';
statusDiv.className = 'loading';
statusDiv.style.display = 'block';
statusDiv.innerHTML = '<div class="spinner"></div> Lade Dateien hoch und verarbeite Bilder... Bitte warten.';
const formData = new FormData(form);
const fetchOptions = {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (csrfToken) {
fetchOptions.headers = {
'X-CSRFToken': csrfToken
};
if (char === '"' && insideQuotes && nextChar === '"') {
currentCell += '"';
i++; // Escaped Quotes ("") überspringen
} else if (char === '"') {
insideQuotes = !insideQuotes;
} else if (char === ',' && !insideQuotes) {
currentRow.push(currentCell);
currentCell = '';
} else if ((char === '\n' || char === '\r') && !insideQuotes) {
if (char === '\r' && nextChar === '\n') i++; // Windows Umbrüche überspringen
currentRow.push(currentCell);
if (currentRow.length > 1 || currentRow[0] !== '') {
rows.push(currentRow);
}
currentRow = [];
currentCell = '';
} else {
currentCell += char;
}
}
if (currentCell !== '' || currentRow.length > 0) {
currentRow.push(currentCell);
if (currentRow.length > 1 || currentRow[0] !== '') {
rows.push(currentRow);
}
}
return rows;
}
// 2. Baut das Array wieder sicher zu einer sauberen CSV-Zeile für das Backend zusammen
function rowToCSV(rowArray) {
return rowArray.map(cell => {
if (cell === null || cell === undefined) return '';
let cellStr = String(cell);
if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n') || cellStr.includes('\r')) {
return '"' + cellStr.replace(/"/g, '""') + '"';
}
return cellStr;
}).join(',');
}
// 3. Bildnamen extrahieren
function extractImageNames(cellValue) {
if (!cellValue) return [];
let raw = String(cellValue).replace(/^["']|["']$/g, '').trim();
if (raw.startsWith('[') && raw.endsWith(']')) {
try {
const jsonValid = raw.replace(/'/g, '"');
return JSON.parse(jsonValid);
} catch (e) {
const matches = raw.match(/['"]([^'"]+)['"]/g);
if (matches) return matches.map(m => m.replace(/['"]/g, ''));
}
}
return raw ? [raw] : [];
}
document.getElementById('batchUploadForm').addEventListener('submit', async function(e) {
e.preventDefault();
const csvInput = document.getElementById('csv_file');
const imageInput = document.getElementById('images');
const uploadBtn = document.getElementById('uploadBtn');
const progressContainer = document.getElementById('uploadProgress');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const logList = document.getElementById('logList');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (!csvInput.files.length) {
alert("Bitte wähle eine CSV-Datei aus.");
return;
}
uploadBtn.disabled = true;
progressContainer.style.display = 'block';
logList.innerHTML = '';
const log = (msg) => {
const li = document.createElement('li');
li.textContent = msg;
logList.appendChild(li);
logList.scrollTop = logList.scrollHeight;
};
const csvFile = csvInput.files[0];
const allImages = Array.from(imageInput.files);
const imageMap = new Map();
allImages.forEach(file => {
imageMap.set(file.name.toLowerCase(), file);
});
const BATCH_SIZE = 20;
try {
const csvText = await csvFile.text();
const allRows = parseCSV(csvText);
if (allRows.length <= 1) {
throw new Error("CSV-Datei ist leer oder enthält nur Kopfzeilen.");
}
const headerRow = allRows[0];
const dataRows = allRows.slice(1);
log(`${dataRows.length} Einträge gefunden. Bereite Batches vor...`);
const imagesColIndex = headerRow.findIndex(h => h.toLowerCase().trim() === 'images');
const batches = [];
for (let i = 0; i < dataRows.length; i += BATCH_SIZE) {
batches.push(dataRows.slice(i, i + BATCH_SIZE));
}
progressBar.max = batches.length;
progressBar.value = 0;
// Sequenzieller Upload mit Fehlertoleranz pro Batch
for (let b = 0; b < batches.length; b++) {
const batchRows = batches[b];
progressText.textContent = `Lade Batch ${b + 1} von ${batches.length} hoch...`;
try {
const response = await fetch('/upload_csv_batch', fetchOptions);
const requiredImagesForBatch = new Set();
if (imagesColIndex !== -1) {
batchRows.forEach(rowCols => {
if (rowCols[imagesColIndex]) {
const imgNames = extractImageNames(rowCols[imagesColIndex]);
imgNames.forEach(name => {
const fileMatch = imageMap.get(name.toLowerCase());
if (fileMatch) {
requiredImagesForBatch.add(fileMatch);
}
});
}
});
}
const batchCsvArray = [headerRow, ...batchRows];
const batchCsvText = batchCsvArray.map(rowToCSV).join('\n');
const batchCsvBlob = new Blob([batchCsvText], { type: 'text/csv' });
const formData = new FormData();
formData.append('csv_file', batchCsvBlob, `batch_${b + 1}.csv`);
if (csrfToken) {
formData.append('csrf_token', csrfToken);
}
requiredImagesForBatch.forEach(imgFile => {
formData.append('images', imgFile);
});
log(`Batch ${b + 1}: ${batchRows.length} Items & ${requiredImagesForBatch.size} zugehörige Bilder.`);
const response = await fetch('/upload_csv_batch', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': csrfToken || ''
}
});
// Antwort einmalig als Text auslesen, um sowohl JSON als auch HTML-Fehler abzufangen
const responseText = await response.text();
let result;
try {
result = JSON.parse(responseText);
} catch (jsonError) {
console.error("Server hat kein JSON gesendet. Antwort war:", responseText);
throw new Error("Der Server hat einen HTML-Fehler zurückgegeben (z.B. Nginx 413 Entity Too Large oder Server-Crash). Siehe F12 Konsole.");
} catch (parseErr) {
throw new Error(`Server-Fehler (Status ${response.status}). HTML statt JSON erhalten.`);
}
if (response.ok && result.success) {
statusDiv.className = 'success';
statusDiv.innerHTML = `<strong>Erfolg!</strong><br>${result.message}`;
form.reset();
} else {
statusDiv.className = 'error';
statusDiv.innerHTML = `<strong>Fehler:</strong> ${result.message || 'Ein unbekannter Fehler ist aufgetreten.'}`;
if (!response.ok || !result.success) {
throw new Error(result.message || `Server-Fehler ${response.status}`);
}
} catch (error) {
statusDiv.className = 'error';
statusDiv.innerHTML = `<strong>Fehler:</strong> ${error.message}`;
console.error('Upload Error:', error);
} finally {
submitBtn.disabled = false;
submitBtn.innerText = 'Daten hochladen';
log(`Batch ${b + 1} erfolgreich abgeschlossen.`);
} catch (batchErr) {
log(`⚠️ FEHLER in Batch ${b + 1}: ${batchErr.message}. Überspringe und fahre fort...`);
console.error(`Batch ${b + 1} fehlgeschlagen:`, batchErr);
}
});
</script>
progressBar.value = b + 1;
}
progressText.textContent = "Upload-Prozess beendet!";
uploadBtn.disabled = false;
} catch (err) {
alert("Upload abgebrochen: " + err.message);
log("Fehler: " + err.message);
uploadBtn.disabled = false;
}
});
</script>
</body>
</html>
+5 -9
View File
@@ -17,15 +17,11 @@
<div class="user-management-container">
<h2>Benutzer</h2>
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
<button
type="submit"
class="btn btn-outline-danger"
onclick="return confirm('Sollen alle gespeicherten Klarnamen dauerhaft in Synonym-Kuerzel umgewandelt werden?')"
>
Gespeicherte Namen anonymisieren
</button>
</form>
<div class="mb-3">
<a href="{{ url_for('register') }}" class="btn btn-success">
Neuen Benutzer registrieren
</a>
</div>
<div class="filter-bar mb-3">
<div class="row g-2 align-items-end">
+35 -17
View File
@@ -166,6 +166,11 @@ def _find_registered_tenant_id(candidate):
return None
def _resolve_registered_tenant_id(candidate):
"""Return the canonical registered tenant id for a candidate, or None."""
return _find_registered_tenant_id(candidate)
def _is_ip_host(hostname):
hostname = str(hostname or '').strip()
if not hostname:
@@ -185,6 +190,8 @@ def get_tenant_config(tenant_id=None):
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
tenant_id = _resolve_registered_tenant_id(tenant_id) or tenant_id
if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {}
@@ -481,20 +488,22 @@ class TenantContext:
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)
matched_tenant = _resolve_registered_tenant_id(tenant_from_query)
if matched_tenant:
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:
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)
matched_tenant = _resolve_registered_tenant_id(tenant_from_header)
if matched_tenant:
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/host based tenant mapping
host_candidates = _request_host_candidates()
@@ -544,11 +553,11 @@ class TenantContext:
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)
matched_tenant = _resolve_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'):])
matched_tenant = _resolve_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'):])
matched_tenant = _resolve_registered_tenant_id('school' + potential_subdomain[len('schule'):])
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
@@ -565,12 +574,21 @@ class TenantContext:
# 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)
matched_tenant = _resolve_registered_tenant_id(session_tenant)
if matched_tenant:
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by session: host={primary_host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
session.pop('tenant_id', None)
logger.info(
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
"Discarded stale tenant session reference: host=%s tenant=%s",
primary_host,
session_tenant,
)
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.
+308 -89
View File
@@ -12,6 +12,250 @@ 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"
NGINX_DOMAIN_BASE="${INVENTAR_TENANT_DOMAIN_BASE:-invario-software.de}"
NGINX_PROXY_HOST="${INVENTAR_NGINX_PROXY_HOST:-172.17.0.1}"
NGINX_SITES_AVAILABLE="${INVENTAR_NGINX_SITES_AVAILABLE:-/etc/nginx/sites-available}"
NGINX_SITES_ENABLED="${INVENTAR_NGINX_SITES_ENABLED:-/etc/nginx/sites-enabled}"
NGINX_SITE_PREFIX="${INVENTAR_NGINX_SITE_PREFIX:-inventarsystem}"
CERTBOT_EMAIL="${INVENTAR_CERTBOT_EMAIL:-}"
tenant_domain() {
printf '%s.%s' "$1" "$NGINX_DOMAIN_BASE"
}
tenant_nginx_site_path() {
printf '%s/%s-%s.conf' "$NGINX_SITES_AVAILABLE" "$NGINX_SITE_PREFIX" "$1"
}
tenant_nginx_enabled_path() {
printf '%s/%s-%s.conf' "$NGINX_SITES_ENABLED" "$NGINX_SITE_PREFIX" "$1"
}
run_host_command() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
return 127
fi
}
reload_host_nginx() {
if command -v nginx >/dev/null 2>&1 || command -v sudo >/dev/null 2>&1; then
if ! run_host_command nginx -t; then
echo "Warning: nginx configuration test failed; skipping reload."
return 1
fi
if command -v systemctl >/dev/null 2>&1; then
if ! run_host_command systemctl reload nginx; then
echo "Warning: systemctl reload nginx failed."
return 1
fi
else
if ! run_host_command nginx -s reload; then
echo "Warning: nginx reload failed."
return 1
fi
fi
fi
}
write_tenant_nginx_config() {
local tenant_id="$1"
local port="$2"
local domain site_path enabled_path
domain="$(tenant_domain "$tenant_id")"
site_path="$(tenant_nginx_site_path "$tenant_id")"
enabled_path="$(tenant_nginx_enabled_path "$tenant_id")"
if [ -z "$port" ]; then
echo "Warning: No port configured for tenant '$tenant_id'; skipping nginx vhost creation."
return 0
fi
if ! run_host_command mkdir -p "$NGINX_SITES_AVAILABLE" "$NGINX_SITES_ENABLED"; then
echo "Warning: Unable to prepare nginx site directories; skipping reverse-proxy setup."
return 0
fi
local have_cert=false
if [ -f "/etc/letsencrypt/live/$domain/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$domain/privkey.pem" ]; then
have_cert=true
else
if [ "$(id -u)" -eq 0 ]; then
cat > "$site_path" <<EOF
# ==========================================
# INVENTARSYSTEM ($tenant_id)
# ==========================================
server {
listen 80;
listen [::]:80;
server_name $domain;
client_max_body_size 100M;
location / {
proxy_pass http://$NGINX_PROXY_HOST:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
else
sudo tee "$site_path" >/dev/null <<EOF
# ==========================================
# INVENTARSYSTEM ($tenant_id)
# ==========================================
server {
listen 80;
listen [::]:80;
server_name $domain;
client_max_body_size 100M;
location / {
proxy_pass http://$NGINX_PROXY_HOST:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
fi
if ! run_host_command ln -sfn "$site_path" "$enabled_path"; then
echo "Warning: Could not enable nginx site for tenant '$tenant_id'."
return 1
fi
if ! reload_host_nginx; then
echo "Warning: Initial nginx reload failed for tenant '$tenant_id'."
fi
if command -v certbot >/dev/null 2>&1 || command -v sudo >/dev/null 2>&1; then
local certbot_args=(certbot --nginx --non-interactive --agree-tos -d "$domain")
if [ -n "$CERTBOT_EMAIL" ]; then
certbot_args+=(--email "$CERTBOT_EMAIL")
else
certbot_args+=(--register-unsafely-without-email)
fi
if ! run_host_command "${certbot_args[@]}"; then
echo "Warning: certbot failed for tenant '$tenant_id'; keeping HTTP-only vhost for now."
return 1
fi
have_cert=true
fi
fi
if [ "$have_cert" != true ]; then
echo "Warning: No TLS certificate available for tenant '$tenant_id'; leaving HTTP-only nginx config in place."
return 1
fi
if [ "$(id -u)" -eq 0 ]; then
cat > "$site_path" <<EOF
# ==========================================
# INVENTARSYSTEM ($tenant_id)
# ==========================================
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name $domain;
client_max_body_size 100M;
ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
location / {
proxy_pass http://$NGINX_PROXY_HOST:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
# Gezielte HTTP zu HTTPS Weiterleitung für $tenant_id
server {
listen 80;
listen [::]:80;
server_name $domain;
return 301 https://$domain\$request_uri;
}
EOF
else
sudo tee "$site_path" >/dev/null <<EOF
# ==========================================
# INVENTARSYSTEM ($tenant_id)
# ==========================================
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name $domain;
client_max_body_size 100M;
ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
location / {
proxy_pass http://$NGINX_PROXY_HOST:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
# Gezielte HTTP zu HTTPS Weiterleitung für $tenant_id
server {
listen 80;
listen [::]:80;
server_name $domain;
return 301 https://$domain\$request_uri;
}
EOF
fi
if ! run_host_command ln -sfn "$site_path" "$enabled_path"; then
echo "Warning: Could not enable nginx site for tenant '$tenant_id'."
return 1
fi
if ! reload_host_nginx; then
echo "Warning: nginx reload after tenant '$tenant_id' configuration update failed."
return 1
fi
}
remove_tenant_nginx_config() {
local tenant_id="$1"
local domain site_path enabled_path
domain="$(tenant_domain "$tenant_id")"
site_path="$(tenant_nginx_site_path "$tenant_id")"
enabled_path="$(tenant_nginx_enabled_path "$tenant_id")"
if [ -f "$enabled_path" ] || [ -L "$enabled_path" ]; then
run_host_command rm -f "$enabled_path" || true
fi
if [ -f "$site_path" ]; then
run_host_command rm -f "$site_path" || true
fi
if command -v certbot >/dev/null 2>&1 || command -v sudo >/dev/null 2>&1; then
run_host_command certbot delete --cert-name "$domain" --non-interactive >/dev/null 2>&1 || true
fi
reload_host_nginx || true
}
ensure_runtime_config_json() {
local config_path backup_path
@@ -201,65 +445,35 @@ sys.path.insert(0, "/app/Web")
from Web.modules.database import settings
from pymongo import MongoClient
import Web.modules.inventarsystem.data_protection as dp
import Web.modules.database.user as us
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_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
db = client[db_name]
pw_bytes = "admin123".encode("utf-8")
random_salt = os.urandom(16)
hashed = hashlib.scrypt(pw_bytes, salt=random_salt, n=16384, r=8, p=1)
hashed_pw_string = f"v1${random_salt.hex()}${hashed.hex()}"
action_permissions = {
"can_borrow": True,
"can_insert": True,
"can_edit": True,
"can_delete": True,
"can_manage_users": True,
"can_manage_settings": True,
"can_view_logs": True,
users = db["users"]
permission_defaults = us.build_default_permission_payload("full_access")
admin_doc = {
"Username": "admin",
"Password": us.hashing("admin123"),
"Admin": True,
"active_ausleihung": None,
"name": dp.encrypt_text("admin"),
"last_name": dp.encrypt_text("admin"),
"IsStudent": False,
"PermissionPreset": permission_defaults["preset"],
"ActionPermissions": permission_defaults["actions"],
"PagePermissions": permission_defaults["pages"],
}
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": dp.encrypt_text("admin")}) == 0:
db.users.insert_one({
"Username": dp.encrypt_text("admin"),
"Password": hashed_pw_string,
"Admin": True,
"active_ausleihung": None,
"name": dp.encrypt_text("Admin"),
"last_name": dp.encrypt_text("User"),
"IsStudent": False,
"PermissionPreset": "full_access",
"ActionPermissions": action_permissions,
"PagePermissions": page_permissions,
})
users.replace_one({"Username": "admin"}, admin_doc, upsert=True)
print("Fallback successfully applied")
if mode == "trial":
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
db = client[db_name]
db.settings.update_one(
{"setting_type": "tenant_trial"},
{"$set": {
@@ -272,6 +486,8 @@ if mode == "trial":
upsert=True,
)
client.close()
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
PY
}
@@ -533,15 +749,18 @@ ${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}).
Legt einen neuen Tenant an, registriert den Port, richtet den nginx-Host
für ${YELLOW}<tenant_id>.invario-software.de${RESET} ein 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.
Läuft automatisch ab, löscht sich selbst und bekommt die gleiche nginx/
Certbot-Anbindung wie ein normaler Tenant.
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank.
Löscht einen Tenant, seine Konfiguration, Ports, nginx-VHost und das
zugehörige Let's-Encrypt-Zertifikat sowie die Datenbank.
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
${GREEN}restart-tenant${RESET} <tenant_id>
@@ -602,11 +821,16 @@ case "$COMMAND" in
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
if ! write_tenant_nginx_config "$TENANT_ID" "$PORT_ARG"; then
echo "Warning: nginx/certbot provisioning for '$TENANT_ID' could not be completed automatically."
fi
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
fi
echo "Adding new tenant '$TENANT_ID'..."
echo "Initializing database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "standard"
@@ -631,6 +855,9 @@ case "$COMMAND" in
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
if ! write_tenant_nginx_config "$TENANT_ID" "$PORT_ARG"; then
echo "Warning: nginx/certbot provisioning for trial tenant '$TENANT_ID' could not be completed automatically."
fi
fi
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
@@ -643,11 +870,11 @@ case "$COMMAND" in
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:-}"
@@ -672,40 +899,31 @@ case "$COMMAND" in
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
# MongoDB-Datenbank via PyMongo direkt im Container droppen
docker exec -i "$APP_CONTAINER" python3 -c '
import sys, os
try:
import pymongo
tenant_id = sys.argv[1]
mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017/")
client = pymongo.MongoClient(mongo_uri, serverSelectionTimeoutMS=2000)
db_name = f"inventar_{tenant_id}"
if db_name in client.list_database_names():
client.drop_database(db_name)
print(f"Dropped database: {db_name}")
except Exception as e:
print(f"Error dropping database: {e}", file=sys.stderr)
' "$TENANT_ID" > /dev/null 2>&1
tenant_id = sys.argv[1]
tenant_cfg = get_tenant_config(tenant_id)
port = tenant_cfg.get('port')
# Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
:
else
port_to_remove=""
fi
# 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."
@@ -719,6 +937,7 @@ PY
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
fi
remove_tenant_nginx_config "$TENANT_ID"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
@@ -728,7 +947,7 @@ PY
else
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
fi
;;
;;
restart-tenant)
TENANT_ID="${2:-}"
+192 -11
View File
@@ -9,13 +9,13 @@ LOG_DIR="$PROJECT_DIR/logs"
LOG_FILE="$LOG_DIR/update.log"
STATE_FILE="$PROJECT_DIR/.release-version"
REPO_SLUG="Invario/Inventarsystem"
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
API_BASE_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
MODE="release"
MODE="stable"
mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true
@@ -119,6 +119,7 @@ usage() {
Usage: $0 [options]
Options:
-dev Install the latest prerelease build
--multitenant Use docker-compose-multitenant.yml (default)
-h, --help Show this help message
EOF
@@ -127,6 +128,10 @@ EOF
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-dev)
MODE="development"
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
@@ -179,7 +184,8 @@ create_backup() {
fetch_release_metadata() {
local meta_file="$1"
curl -fsSL "$API_URL" -o "$meta_file"
local endpoint="${2:-latest}"
curl -fsSL "$API_BASE_URL/releases/$endpoint" -o "$meta_file"
}
parse_latest_tag() {
@@ -207,6 +213,124 @@ for asset in data.get('assets', []):
PY
}
parse_release_tag() {
local meta_file="$1"
python3 - <<'PY' "$meta_file"
import json, sys
with open(sys.argv[1], 'r', encoding='utf-8') as f:
data = json.load(f)
print(data.get('tag_name', '').strip())
PY
}
strip_prerelease_suffix() {
local tag="$1"
python3 - <<'PY' "$tag"
import re, sys
tag = sys.argv[1].strip()
match = re.match(r'^(v\d+\.\d+\.\d+)', tag)
print(match.group(1) if match else tag)
PY
}
compare_semver_tags() {
local left_tag="$1"
local right_tag="$2"
python3 - <<'PY' "$left_tag" "$right_tag"
import re, sys
def parse(tag):
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)', tag.strip())
if not match:
return None
return tuple(int(part) for part in match.groups())
left = parse(sys.argv[1])
right = parse(sys.argv[2])
if left is None or right is None:
print('unknown')
elif left > right:
print('gt')
elif left < right:
print('lt')
else:
print('eq')
PY
}
parse_prerelease_flag() {
local meta_file="$1"
python3 - <<'PY' "$meta_file"
import json, sys
with open(sys.argv[1], 'r', encoding='utf-8') as f:
data = json.load(f)
print('true' if data.get('prerelease') else 'false')
PY
}
latest_stable_release() {
local meta_file="$1"
local releases_file="$2"
local release_count=0
if ! curl -fsSL "$API_BASE_URL/releases" -o "$releases_file"; then
return 1
fi
if ! python3 - <<'PY' "$releases_file" "$meta_file"
import json, sys
releases_path, target_path = sys.argv[1], sys.argv[2]
with open(releases_path, 'r', encoding='utf-8') as f:
releases = json.load(f)
for release in releases:
if not release.get('prerelease') and not release.get('draft'):
with open(target_path, 'w', encoding='utf-8') as out:
json.dump(release, out)
raise SystemExit(0)
raise SystemExit(1)
PY
then
return 1
fi
return 0
}
latest_prerelease() {
local meta_file="$1"
local releases_file="$2"
if ! curl -fsSL "$API_BASE_URL/releases" -o "$releases_file"; then
return 1
fi
if ! python3 - <<'PY' "$releases_file" "$meta_file"
import json, sys
releases_path, target_path = sys.argv[1], sys.argv[2]
with open(releases_path, 'r', encoding='utf-8') as f:
releases = json.load(f)
for release in releases:
if release.get('prerelease') and not release.get('draft'):
with open(target_path, 'w', encoding='utf-8') as out:
json.dump(release, out)
raise SystemExit(0)
raise SystemExit(1)
PY
then
return 1
fi
return 0
}
fetch_latest_release_bundle() {
local release_meta="$1"
local bundle_url
bundle_url="$(parse_asset_url "$release_meta" "$BUNDLE_ASSET")"
if [ -z "$bundle_url" ]; then
return 1
fi
printf '%s' "$bundle_url"
}
refresh_runtime_scripts_from_main() {
local start_url stop_url restart_url update_url
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
@@ -368,12 +492,66 @@ main() {
archive_logs
create_backup
# If user requested a development install, perform a simple dev deploy flow
# If user requested a development install, deploy the latest prerelease.
if [ "$MODE" = "development" ]; then
log_message "Requested development install"
local tag="dev"
local app_image="$APP_IMAGE_REPO:$tag"
local compose_path="$PROJECT_DIR/$COMPOSE_FILE"
local tag meta_file releases_file stable_meta_file stable_tag prerelease_tag prerelease_base_tag bundle_url app_image compose_path
local tmp_dir
tmp_dir="$(mktemp -d)"
meta_file="$tmp_dir/prerelease.json"
stable_meta_file="$tmp_dir/stable.json"
releases_file="$tmp_dir/releases.json"
trap 'rm -rf "${tmp_dir:-}"' EXIT
if ! fetch_release_metadata "$stable_meta_file" "latest"; then
log_message "ERROR: Could not fetch latest stable release metadata"
exit 1
fi
stable_tag="$(parse_release_tag "$stable_meta_file")"
if [ -z "$stable_tag" ]; then
log_message "ERROR: Could not determine latest stable release tag"
exit 1
fi
if latest_prerelease "$meta_file" "$releases_file"; then
prerelease_tag="$(parse_release_tag "$meta_file")"
prerelease_base_tag="$(strip_prerelease_suffix "$prerelease_tag")"
else
prerelease_tag=""
prerelease_base_tag=""
fi
if [ -n "$prerelease_tag" ]; then
case "$(compare_semver_tags "$stable_tag" "$prerelease_base_tag")" in
gt|eq)
log_message "Latest stable release ($stable_tag) supersedes prerelease ($prerelease_tag); using stable release"
tag="$stable_tag"
meta_file="$stable_meta_file"
;;
lt)
tag="$prerelease_tag"
;;
*)
log_message "WARNING: Could not compare stable and prerelease versions; preferring prerelease when available"
tag="$prerelease_tag"
;;
esac
else
log_message "No prerelease found; falling back to stable release $stable_tag"
tag="$stable_tag"
meta_file="$stable_meta_file"
fi
bundle_url="$(fetch_latest_release_bundle "$meta_file")"
if [ -z "$bundle_url" ]; then
log_message "ERROR: Release asset not found: $BUNDLE_ASSET"
exit 1
fi
app_image="$APP_IMAGE_REPO:$tag"
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
log_message "ERROR: compose file not found: $compose_path"
@@ -399,6 +577,9 @@ EOF
exit 1
fi
download_and_extract_bundle "$bundle_url" "$tmp_dir"
refresh_runtime_scripts_from_main
# Bring up stack
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
@@ -409,7 +590,7 @@ EOF
fi
echo "$tag" > "$STATE_FILE"
log_message "Development update completed successfully"
log_message "Development update completed successfully to prerelease $tag"
exit 0
fi
@@ -419,8 +600,8 @@ EOF
trap 'rm -rf "${tmp_dir:-}"' EXIT
log_message "Checking latest Gitea release for $REPO_SLUG..."
if ! fetch_release_metadata "$meta_file"; then
log_message "Checking latest stable Gitea release for $REPO_SLUG..."
if ! fetch_release_metadata "$meta_file" "latest"; then
log_message "WARNING: Could not fetch release metadata. Falling back to self-healing start path."
if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then
if verify_stack_health; then
@@ -436,7 +617,7 @@ EOF
exit 0
fi
latest_tag="$(parse_latest_tag "$meta_file")"
latest_tag="$(parse_release_tag "$meta_file")"
if [ -z "$latest_tag" ]; then
log_message "WARNING: Could not determine latest release tag. Falling back to self-healing start path."
if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then