Compare commits

...

34 Commits

Author SHA1 Message Date
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
Aiirondev_dev 27f5280bbf fix of the json upload format 2026-08-03 19:36:56 +02:00
Aiirondev_dev 82898e34cb fix of the json upload format 2026-08-03 19:16:32 +02:00
Aiirondev_dev 44392c2c31 fix of the json upload format 2026-08-03 18:51:47 +02:00
Aiirondev_dev 58d94b716f fix of the json upload format 2026-08-03 18:34:27 +02:00
Aiirondev_dev 579a0ddb75 fix of the json upload format 2026-08-03 18:22:27 +02:00
Aiirondev_dev 0f21e8d9ca fix of the json upload format 2026-08-03 01:02:40 +02:00
Aiirondev_dev 12f7240cd2 fix of the json upload format 2026-08-03 00:52:39 +02:00
Aiirondev_dev 54d8d61358 fix of the json upload format 2026-08-03 00:28:53 +02:00
9 changed files with 1100 additions and 371 deletions
+83 -9
View File
@@ -4,17 +4,20 @@ on:
push: push:
tags: tags:
- "v*" - "v*"
branches:
- main
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump: release_type:
description: "Version bump type (major stays fixed from latest release)" description: "Release type"
required: false required: false
default: "patch" default: "patch"
type: choice type: choice
options: options:
- patch
- minor
- major - major
- minor
- patch
- dev
permissions: permissions:
contents: write contents: write
@@ -38,12 +41,70 @@ jobs:
REPO: ${{ gitea.repository }} REPO: ${{ gitea.repository }}
EVENT_NAME: ${{ gitea.event_name }} EVENT_NAME: ${{ gitea.event_name }}
REF_NAME: ${{ gitea.ref_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' DOCKER_API_VERSION: '1.44'
run: | 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" TAG="$REF_NAME"
else else
prerelease=false
latest_tag="v0.8.31" 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 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) 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 major=0; minor=8; patch=31
fi fi
if [ "${BUMP_TYPE:-}" = "major" ]; then if [ "${RELEASE_TYPE:-}" = "major" ]; then
major=$((major + 1)); minor=0; patch=0 major=$((major + 1)); minor=0; patch=0
elif [ "${BUMP_TYPE:-}" = "minor" ]; then elif [ "${RELEASE_TYPE:-}" = "minor" ]; then
minor=$((minor + 1)); patch=0 minor=$((minor + 1)); patch=0
else else
patch=$((patch + 1)) patch=$((patch + 1))
fi fi
TAG="v${major}.${minor}.${patch}" TAG="v${major}.${minor}.${patch}"
fi fi
@@ -109,6 +170,7 @@ jobs:
echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT" echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT" echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT"
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
- name: Ensure Docker CLI is available and up to date - name: Ensure Docker CLI is available and up to date
run: | run: |
@@ -148,6 +210,7 @@ jobs:
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push release image - name: Build and push release image
if: steps.meta.outputs.prerelease != 'true'
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
@@ -157,6 +220,16 @@ jobs:
${{ steps.meta.outputs.image }} ${{ steps.meta.outputs.image }}
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest 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 - name: Create release-only docker bundle
run: | run: |
mkdir -p release-bundle mkdir -p release-bundle
@@ -253,5 +326,6 @@ jobs:
uses: https://gitea.com/actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
with: with:
tag_name: ${{ steps.meta.outputs.tag }} tag_name: ${{ steps.meta.outputs.tag }}
prerelease: ${{ steps.meta.outputs.prerelease }}
files: | files: |
inventarsystem-docker-bundle.tar.gz inventarsystem-docker-bundle.tar.gz
+128 -55
View File
@@ -108,7 +108,7 @@ app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER
app.config['THUMBNAIL_FOLDER'] = cfg.THUMBNAIL_FOLDER app.config['THUMBNAIL_FOLDER'] = cfg.THUMBNAIL_FOLDER
app.config['PREVIEW_FOLDER'] = cfg.PREVIEW_FOLDER app.config['PREVIEW_FOLDER'] = cfg.PREVIEW_FOLDER
app.config['ALLOWED_EXTENSIONS'] = set(cfg.ALLOWED_EXTENSIONS) app.config['ALLOWED_EXTENSIONS'] = set(cfg.ALLOWED_EXTENSIONS)
app.config['MAX_CONTENT_LENGTH'] = max(cfg.MAX_UPLOAD_MB, cfg.IMAGE_MAX_UPLOAD_MB, cfg.VIDEO_MAX_UPLOAD_MB) * 1024 * 1024 app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024
app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SESSION_COOKIE_SECURE') is None else os.getenv('INVENTAR_SESSION_COOKIE_SECURE', '').strip().lower() in ('1', 'true', 'yes', 'on') app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SESSION_COOKIE_SECURE') is None else os.getenv('INVENTAR_SESSION_COOKIE_SECURE', '').strip().lower() in ('1', 'true', 'yes', 'on')
@@ -665,11 +665,14 @@ def handle_unexpected_exception(e):
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'): def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}: # NEU: '/upload_csv_batch' zur Liste hinzufügen, damit Fehler als JSON gesendet werden
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image',
'/log_mobile_issue',
'/upload_csv_batch'}:
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
flash(message, 'error') flash(message, 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
def _get_current_module(path): def _get_current_module(path):
"""Resolve the active UI module for navbar separation.""" """Resolve the active UI module for navbar separation."""
mod = cfg.MODULES.get_module_for_path(path) mod = cfg.MODULES.get_module_for_path(path)
@@ -11859,23 +11862,58 @@ def batch_upload_page():
return render_template('upload_batch.html') return render_template('upload_batch.html')
def clean_db_field(val):
"""Bereinigt Werte, die fälschlicherweise als String-Listen oder mit Klammern aus der CSV kommen."""
import ast
import pandas as pd
if not val or pd.isna(val):
return None
val_str = str(val).strip()
# Wenn es wie eine Liste aussieht (z.B. "['100177']" oder "['']")
if val_str.startswith("[") and val_str.endswith("]"):
try:
parsed = ast.literal_eval(val_str)
if isinstance(parsed, list):
# Nimm das erste Element der Liste, wenn vorhanden
for item in parsed:
cleaned_item = str(item).strip()
if cleaned_item and cleaned_item not in ("", "''", '""', "None", "nan"):
return cleaned_item
return None
except Exception:
# Fallback bei Syntaxfehlern
inner = val_str[1:-1].strip().replace("'", "").replace('"', '')
return inner if inner and inner not in ("''", '""') else None
if val_str in ("[]", "['']", '[""]', "nan", "None", "''", '""'):
return None
return val_str
@app.route('/upload_csv_batch', methods=['POST']) @app.route('/upload_csv_batch', methods=['POST'])
def upload_csv_batch(): def upload_csv_batch():
""" """
Route for batch adding new items to the inventory via CSV. Route for batch adding new items to the inventory via CSV.
Handles CSV parsing, bulk image upload (conversion to WebP), GridFS storage, Handles CSV parsing, bulk image upload with deduplication (SHA-256 hash matching),
and groups identical items based on their Name. GridFS storage, code generation, location syncing, and grouped item creation.
""" """
import pandas as pd import pandas as pd
import ast import ast
if 'username' not in session: import hashlib
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
username = session['username'] username = session.get('username', 'System')
# permissions = _get_current_user_permissions() ... (anpassen wie in Original)
# if not _action_access_allowed(permissions, 'can_insert'): def generate_unique_batch_code(base_code, position):
# return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403 if base_code:
return f"{base_code}-{position}"
else:
random_prefix = str(uuid.uuid4())[:6].upper()
return f"BATCH-{random_prefix}-{position}"
fs = get_gridfs() fs = get_gridfs()
upload_session_id = str(uuid.uuid4())[:8] upload_session_id = str(uuid.uuid4())[:8]
@@ -11890,7 +11928,7 @@ def upload_csv_batch():
# 2. CSV Einlesen und Validieren # 2. CSV Einlesen und Validieren
try: try:
df = pd.read_csv(csv_file) df = pd.read_csv(csv_file, sep=',')
except Exception as e: except Exception as e:
app.logger.error(f"[Upload {upload_session_id}] Fehler beim Lesen der CSV: {str(e)}") app.logger.error(f"[Upload {upload_session_id}] Fehler beim Lesen der CSV: {str(e)}")
return jsonify({"success": False, "message": f"Fehler beim Lesen der CSV: {str(e)}"}), 400 return jsonify({"success": False, "message": f"Fehler beim Lesen der CSV: {str(e)}"}), 400
@@ -11898,10 +11936,11 @@ def upload_csv_batch():
if 'Name' not in df.columns: if 'Name' not in df.columns:
return jsonify({"success": False, "message": "Die CSV muss zwingend eine 'Name' Spalte enthalten."}), 400 return jsonify({"success": False, "message": "Die CSV muss zwingend eine 'Name' Spalte enthalten."}), 400
# 3. Bilder verarbeiten, nach WebP konvertieren und in GridFS speichern # 3. Bilder verarbeiten & Duplikate im selben Durchlauf filtern (Hash-Matching)
# Mapping: Original-Dateiname (ohne Pfad/Erweiterung) -> GridFS Filename (.webp)
image_mapping = {} image_mapping = {}
processed_hashes = {}
processed_count = 0 processed_count = 0
dedup_count = 0
error_count = 0 error_count = 0
for index, image in enumerate(uploaded_images): for index, image in enumerate(uploaded_images):
@@ -11913,14 +11952,20 @@ def upload_csv_batch():
image_log_prefix = f"[Upload {upload_session_id}][Image {index + 1}/{len(uploaded_images)}]" image_log_prefix = f"[Upload {upload_session_id}][Image {index + 1}/{len(uploaded_images)}]"
try: try:
# Annahme: is_allowed, error_message = allowed_file(...)
image.seek(0) image.seek(0)
image_bytes = image.read() image_bytes = image.read()
if not image_bytes: if not image_bytes:
error_count += 1 error_count += 1
continue continue
img_hash = hashlib.sha256(image_bytes).hexdigest()
if img_hash in processed_hashes:
existing_filename = processed_hashes[img_hash]
image_mapping[base_name_no_ext] = existing_filename
dedup_count += 1
continue
optimized_io = io.BytesIO() optimized_io = io.BytesIO()
with Image.open(io.BytesIO(image_bytes)) as img: with Image.open(io.BytesIO(image_bytes)) as img:
if img.mode not in ('RGB', 'RGBA'): if img.mode not in ('RGB', 'RGBA'):
@@ -11937,8 +11982,7 @@ def upload_csv_batch():
optimized_io.seek(0) optimized_io.seek(0)
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp" new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
# Speichern in GridFS analog zu upload_item fs.put(
file_id = fs.put(
optimized_io, optimized_io,
filename=new_filename, filename=new_filename,
content_type='image/webp', content_type='image/webp',
@@ -11949,7 +11993,7 @@ def upload_csv_batch():
} }
) )
# Im Mapping speichern (damit wir sie später der CSV zuordnen können) processed_hashes[img_hash] = new_filename
image_mapping[base_name_no_ext] = new_filename image_mapping[base_name_no_ext] = new_filename
processed_count += 1 processed_count += 1
@@ -11957,11 +12001,14 @@ def upload_csv_batch():
app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}") app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}")
error_count += 1 error_count += 1
# 4. Items gruppieren (Analog zu series_group_id aus upload_item) # 4. Predefined Locations laden
# Gruppierung über den Namen: Alle Zeilen mit demselben Namen gehören zur selben Serie try:
df['Name'] = df['Name'].fillna('Unbenannt').astype(str) predefined_locations = it.get_predefined_locations()
except Exception:
predefined_locations = []
# Optional: Fülle NaN Werte in der CSV mit sinnvollen Defaults für die Datenbank # 5. Dataframe bereinigen
df['Name'] = df['Name'].fillna('Unbenannt').astype(str).str.strip()
df = df.fillna({ df = df.fillna({
'Ort': 'Unbekannt', 'Ort': 'Unbekannt',
'Beschreibung': '', 'Beschreibung': '',
@@ -11970,40 +12017,59 @@ def upload_csv_batch():
'Anschaffungskosten': '' 'Anschaffungskosten': ''
}) })
# --- WICHTIG: Gruppierung über einen normalisierten Schlüssel ermöglichen ---
# Erstellt eine unsichtbare Hilfsspalte, die Leerzeichen/Groß-Kleinschreibung ignoriert,
# damit identische Artikel-Typen sauber als Serie erkannt werden.
df['GroupKey'] = df['Name'].str.lower()
created_item_ids = [] created_item_ids = []
grouped_items = df.groupby('GroupKey')
grouped_items = df.groupby('Name') for group_key, group in grouped_items:
for name, group in grouped_items:
item_count = len(group) item_count = len(group)
series_group_id = str(uuid.uuid4()) if item_count > 1 else None series_group_id = str(uuid.uuid4()) if item_count > 1 else None
parent_item_id = None parent_item_id = None
# Originalen Namen des ersten Elements der Gruppe übernehmen
actual_group_name = group.iloc[0]['Name']
# Basis-Code für automatisierte Seriencodes ermitteln
first_row_code = clean_db_field(group.iloc[0].get('Code_4', ''))
base_code = first_row_code if first_row_code else None
for position, (index, row) in enumerate(group.iterrows(), start=1): for position, (index, row) in enumerate(group.iterrows(), start=1):
# Bilder aus der CSV-Zeile extrahieren und über das image_mapping mappen ort_val = str(row['Ort']).strip()
if ort_val and ort_val not in predefined_locations:
try:
it.add_predefined_location(ort_val)
predefined_locations.append(ort_val)
except Exception as e:
app.logger.warning(f"Ort {ort_val} konnte nicht hinzugefügt werden: {e}")
# Bilder zuordnen und pro Artikel deduplizieren
item_image_filenames = [] item_image_filenames = []
if 'Images' in row and pd.notna(row['Images']): if 'Images' in row and pd.notna(row['Images']):
try: try:
# Aus "['Bild1.JPG', 'Bild2.JPG']" wird eine Liste
img_list = ast.literal_eval(str(row['Images'])) img_list = ast.literal_eval(str(row['Images']))
if isinstance(img_list, list): if isinstance(img_list, list):
for img_name in img_list: for img_name in img_list:
base_img_name = os.path.splitext(img_name)[0] base_img_name = os.path.splitext(img_name)[0]
# Falls das Bild hochgeladen wurde, die WebP GridFS ID/Name nehmen
if base_img_name in image_mapping: if base_img_name in image_mapping:
item_image_filenames.append(image_mapping[base_img_name]) item_image_filenames.append(image_mapping[base_img_name])
else:
app.logger.warning(f"Bild {img_name} in CSV definiert, aber nicht hochgeladen.")
except (ValueError, SyntaxError): except (ValueError, SyntaxError):
pass pass
# Filter extrahieren (falls vorhanden, erwarte string list wie "['HSU', '', '', '']") unique_image_filenames = []
for img in item_image_filenames:
if img not in unique_image_filenames:
unique_image_filenames.append(img)
def parse_filter_col(col_data): def parse_filter_col(col_data):
try: try:
res = ast.literal_eval(str(col_data)) res = ast.literal_eval(str(col_data))
return res if isinstance(res, list) else [] return res if isinstance(res, list) else []
except: except Exception:
return [] return []
filter_upload = parse_filter_col(row.get('Filter', '[]')) filter_upload = parse_filter_col(row.get('Filter', '[]'))
@@ -12012,46 +12078,53 @@ def upload_csv_batch():
reservierbar = bool(row.get('Reservierbar', False)) reservierbar = bool(row.get('Reservierbar', False))
# DB Insert Funktion aufrufen (orientiert an deiner upload_item) # Code_4 / Barcode sauber extrahieren und bereinigen
raw_code = row.get('Code_4') or row.get('Barcode') or ''
row_code = clean_db_field(raw_code)
if row_code:
unique_code = row_code
elif item_count > 1:
unique_code = generate_unique_batch_code(base_code, position)
else:
unique_code = None
# DB Insert
item_id = it.add_item( item_id = it.add_item(
name=row['Name'], str(actual_group_name), # 1. Name
ort=row['Ort'], ort_val, # 2. Ort
beschreibung=row['Beschreibung'], str(row['Beschreibung']), # 3. Beschreibung
image_filenames=item_image_filenames, unique_image_filenames, # 4. Image Filenames (GridFS)
filter_upload=filter_upload, filter_upload, # 5. Filter 1
filter_upload2=filter_upload2, filter_upload2, # 6. Filter 2
filter_upload3=filter_upload3, filter_upload3, # 7. Filter 3
anschaffungs_jahr=str(row['Anschaffungsjahr']) if row['Anschaffungsjahr'] else None, clean_db_field(row.get('Anschaffungsjahr')), # 8. Jahr
anschaffungs_kosten=str(row['Anschaffungskosten']) if row['Anschaffungskosten'] else None, clean_db_field(row.get('Anschaffungskosten')),# 9. Kosten
code_4=str(row['Code_4']) if row['Code_4'] else None, unique_code, # 10. Unique Code / Code_4
reservierbar=reservierbar, reservierbar=reservierbar,
series_group_id=series_group_id, series_group_id=series_group_id,
series_count=item_count, series_count=item_count,
series_position=position, series_position=position,
is_grouped_sub_item=(position > 1), is_grouped_sub_item=(position > 1),
parent_item_id=parent_item_id, parent_item_id=parent_item_id,
# Default Werte, falls keine Bibliotheks-CSV isbn=str(row.get('ISBN', '')),
isbn='', item_type=str(row.get('Item_Type', 'other')),
item_type='other', library_category=str(row.get('Library_Category', '')),
library_category='', is_library=bool(row.get('Is_Library', False))
is_library=False
) )
if item_id: if item_id:
created_item_ids.append(item_id) created_item_ids.append(item_id)
# Das erste Item in einer Serie wird der Parent für die restlichen
if position == 1: if position == 1:
parent_item_id = str(item_id) parent_item_id = str(item_id)
else: else:
app.logger.error(f"Fehler beim Erstellen von Item: {row['Name']} (Index {index})") app.logger.error(f"Fehler beim Erstellen von Item: {actual_group_name} (Index {index})")
app.logger.info(
f"Batch Upload abgeschlossen: {len(created_item_ids)} Items erstellt. {processed_count} Bilder verarbeitet.")
return jsonify({ return jsonify({
"success": True, "success": True,
"message": f"Upload erfolgreich. {len(created_item_ids)} Items importiert und {processed_count} Bilder konvertiert.", "message": f"Upload erfolgreich. {len(created_item_ids)} Items importiert.",
"created_count": len(created_item_ids), "created_count": len(created_item_ids),
"images_processed": processed_count, "images_processed": processed_count,
"images_deduplicated": dedup_count,
"images_failed": error_count "images_failed": error_count
}), 200 }), 200
+119 -4
View File
@@ -54,10 +54,34 @@ def _clean_name_fragment(value):
return cleaned 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.""" """Return the current tenant database for the request, or fall back to default."""
try: try:
from tenant import get_tenant_db 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) return get_tenant_db(client)
except Exception: except Exception:
return client[cfg.MONGODB_DB] return client[cfg.MONGODB_DB]
@@ -479,8 +503,45 @@ def check_nm_pwd(username, password):
query = {'$or': [{'Username': username}, {'username': username}]} query = {'$or': [{'Username': username}, {'username': username}]}
user_record_fallback = users.find_one(query) user_record_fallback = users.find_one(query)
if user_record_fallback is None: if user_record_fallback is None:
logger.warning("Kein Benutzer für %r in DB %r gefunden.", dp.encrypt_text(username), db_name) if db_name != cfg.MONGODB_DB:
return None default_users = client[cfg.MONGODB_DB]['users']
user_record_fallback = default_users.find_one(
{'$or': [
{'Username': dp.encrypt_text(username)},
{'username': dp.encrypt_text(username)},
{'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: else:
user_record = user_record_fallback user_record = user_record_fallback
@@ -497,6 +558,59 @@ def check_nm_pwd(username, password):
finally: finally:
client.close() client.close()
def add_admin(
username,
password,
name='',
last_name='',
is_student=False,
permission_preset='full_access',
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': 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( def add_user(
username, username,
@@ -509,6 +623,7 @@ def add_user(
permission_preset='standard_user', permission_preset='standard_user',
action_permissions=None, action_permissions=None,
page_permissions=None, page_permissions=None,
tenant_id=None,
): ):
""" """
Add a new user to the database. Add a new user to the database.
@@ -518,7 +633,7 @@ def add_user(
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try: try:
db = _get_tenant_db(client) db = _get_tenant_db(client, tenant_id)
users = db['users'] users = db['users']
permission_defaults = build_default_permission_payload(permission_preset) permission_defaults = build_default_permission_payload(permission_preset)
+1 -1
View File
@@ -229,7 +229,7 @@
<div class="container"> <div class="container">
<div class="student-card-header"> <div class="student-card-header">
<div> <div>
<h1>📚 Bibliotheksausweise (Bibliotek)</h1> <h1>📚 Bibliotheksausweise (Bibliothek)</h1>
</div> </div>
<div class="export-buttons"> <div class="export-buttons">
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a> <a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
+3 -3
View File
@@ -280,7 +280,7 @@
<button type="button" data-target-step="0">1. Startseite</button> <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> <button type="button" data-target-step="1">2. {{ 'Artikel erfassen' if is_admin else 'Artikel finden' }}</button>
{% if library_module_enabled %} {% 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 %} {% endif %}
<button type="button" data-target-step="{{ '3' if library_module_enabled else '2' }}">4. Alltag</button> <button type="button" data-target-step="{{ '3' if library_module_enabled else '2' }}">4. Alltag</button>
</div> </div>
@@ -341,7 +341,7 @@
{% if library_module_enabled %} {% if library_module_enabled %}
<article class="workflow-step" data-step-index="2" data-step-key="library"> <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> <p>Dieser Bereich ist speziell für Bücher und Medienausleihe.</p>
<ul> <ul>
{% if is_admin %} {% if is_admin %}
@@ -358,7 +358,7 @@
</ul> </ul>
<div class="tutorial-actions"> <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 %} {% if is_admin %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_loans_admin') }}">Ausleihen ansehen</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_loans_admin') }}">Ausleihen ansehen</a>
{% endif %} {% endif %}
+231 -182
View File
@@ -3,211 +3,260 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <style>
:root { body { font-family: sans-serif; padding: 20px; background: #f4f7f6; }
--primary-color: #4a90e2; .upload-container { background: white; padding: 20px; border-radius: 8px; max-width: 500px; margin: 0 auto; }
--background-color: #f4f7f6; .form-group { margin-bottom: 15px; }
--text-color: #333; label { display: block; font-weight: bold; margin-bottom: 5px; }
--border-radius: 8px; 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; }
body { #uploadProgress { margin-top: 20px; display: none; }
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; progress { width: 100%; height: 20px; }
background-color: var(--background-color); #logList { background: #fafafa; border: 1px solid #eee; padding: 10px; max-height: 150px; overflow-y: auto; font-size: 0.85em; list-style: none; }
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); }
}
</style> </style>
</head> </head>
<body> <body>
<div class="upload-container"> <div class="upload-container">
<h2>Inventar Batch Upload</h2> <h2>Inventar Batch Upload</h2>
<form id="uploadForm"> <form id="batchUploadForm">
<div class="form-group"> <div class="form-group">
<label for="csv_file">1. items.csv Datei auswählen</label> <label for="csv_file">1. items.csv auswählen</label>
<!-- Akzeptiert nur CSV Dateien --> <input type="file" id="csv_file" name="csv_file" accept=".csv" required>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required> </div>
</div>
<div class="form-group"> <div class="form-group">
<label for="images">2. Bilder auswählen</label> <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>
<input type="file" id="images" name="images" accept="image/*" multiple required> </div>
<small style="color: #666; display: block; margin-top: 5px;">Du kannst mehrere Bilder markieren (Strg/Cmd gedrückt halten).</small>
</div>
<button type="submit" id="submitBtn" class="btn-submit">Daten hochladen</button> <button type="submit" id="uploadBtn" class="btn-submit">Daten hochladen</button>
</form> </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>
</div>
<script> <script>
document.getElementById('uploadForm').addEventListener('submit', async function(e) { // 1. Robuster CSV-Parser, der Zeilenumbrüche und Kommas in Texten korrekt ignoriert
e.preventDefault(); // Verhindert das Neuladen der Seite function parseCSV(csvString) {
const rows = [];
let currentRow = [];
let currentCell = '';
let insideQuotes = false;
const form = e.target; for (let i = 0; i < csvString.length; i++) {
const submitBtn = document.getElementById('submitBtn'); const char = csvString[i];
const statusDiv = document.getElementById('status-message'); const nextChar = csvString[i + 1];
// UI auf "Laden" setzen if (char === '"' && insideQuotes && nextChar === '"') {
submitBtn.disabled = true; currentCell += '"';
submitBtn.innerText = 'Wird verarbeitet...'; i++; // Escaped Quotes ("") überspringen
statusDiv.className = 'loading'; } else if (char === '"') {
statusDiv.style.display = 'block'; insideQuotes = !insideQuotes;
statusDiv.innerHTML = '<div class="spinner"></div> Lade Dateien hoch und verarbeite Bilder... Bitte warten.'; } 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;
}
// FormData sammelt alle Inputs aus dem Formular (csv_file und images) // 2. Baut das Array wieder sicher zu einer sauberen CSV-Zeile für das Backend zusammen
const formData = new FormData(form); 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 { try {
// Sende die Daten an den Flask-Endpoint const requiredImagesForBatch = new Set();
const response = await fetch('/upload_csv_batch', {
method: 'POST', if (imagesColIndex !== -1) {
body: formData 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 || ''
}
});
const responseText = await response.text();
let result; let result;
try { try {
// Versuche, die Antwort als JSON zu lesen result = JSON.parse(responseText);
result = await response.json(); } catch (parseErr) {
} catch (jsonError) { throw new Error(`Server-Fehler (Status ${response.status}). HTML statt JSON erhalten.`);
// Wenn der Server kein JSON, sondern HTML (z.B. bei einem Python-Crash) sendet
const errorText = await response.text();
console.error("Server hat kein JSON gesendet. Antwort war:", errorText);
throw new Error("Der Server hat einen HTML-Fehler zurückgegeben (Python-Crash oder falscher Pfad). Siehe Konsole.");
} }
if (response.ok && result.success) { if (!response.ok || !result.success) {
// Erfolgreicher Upload throw new Error(result.message || `Server-Fehler ${response.status}`);
statusDiv.className = 'success';
statusDiv.innerHTML = `
<strong>Erfolg!</strong><br>
${result.message}
`;
form.reset(); // Formular nach Erfolg leeren
} else {
// Fehler vom Server (mit JSON-Fehlermeldung)
statusDiv.className = 'error';
statusDiv.innerHTML = `<strong>Fehler:</strong> ${result.message || 'Ein unbekannter Fehler ist aufgetreten.'}`;
} }
} catch (error) { log(`Batch ${b + 1} erfolgreich abgeschlossen.`);
// Netzwerkfehler oder abgefangener Server-Fehler
statusDiv.className = 'error'; } catch (batchErr) {
statusDiv.innerHTML = `<strong>Fehler:</strong> ${error.message}`; log(`⚠️ FEHLER in Batch ${b + 1}: ${batchErr.message}. Überspringe und fahre fort...`);
console.error('Upload Error:', error); console.error(`Batch ${b + 1} fehlgeschlagen:`, batchErr);
} finally {
// UI wieder freigeben
submitBtn.disabled = false;
submitBtn.innerText = 'Daten hochladen';
} }
});
</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>
+35 -17
View File
@@ -166,6 +166,11 @@ def _find_registered_tenant_id(candidate):
return None 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): def _is_ip_host(hostname):
hostname = str(hostname or '').strip() hostname = str(hostname or '').strip()
if not hostname: if not hostname:
@@ -185,6 +190,8 @@ def get_tenant_config(tenant_id=None):
ctx = get_tenant_context() ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default' tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
tenant_id = _resolve_registered_tenant_id(tenant_id) or tenant_id
if tenant_id in TENANT_REGISTRY: if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {} return TENANT_REGISTRY[tenant_id] or {}
@@ -481,20 +488,22 @@ class TenantContext:
or request.args.get('tenantId', '').strip() or request.args.get('tenantId', '').strip()
) )
if tenant_from_query: if tenant_from_query:
matched_tenant = _find_registered_tenant_id(tenant_from_query) or tenant_from_query matched_tenant = _resolve_registered_tenant_id(tenant_from_query)
self.tenant_id = matched_tenant if matched_tenant:
self.config = get_tenant_config(matched_tenant) self.tenant_id = matched_tenant
session['tenant_id'] = matched_tenant self.config = get_tenant_config(matched_tenant)
return self._get_db_name(matched_tenant) session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 1: X-Tenant-ID header (for testing/internal APIs) # Priority 1: X-Tenant-ID header (for testing/internal APIs)
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip() tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header: if tenant_from_header:
matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header matched_tenant = _resolve_registered_tenant_id(tenant_from_header)
self.tenant_id = matched_tenant if matched_tenant:
self.config = get_tenant_config(matched_tenant) self.tenant_id = matched_tenant
session['tenant_id'] = matched_tenant self.config = get_tenant_config(matched_tenant)
return self._get_db_name(matched_tenant) session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 2: Port/host based tenant mapping # Priority 2: Port/host based tenant mapping
host_candidates = _request_host_candidates() host_candidates = _request_host_candidates()
@@ -544,11 +553,11 @@ class TenantContext:
if len(parts) >= 2: if len(parts) >= 2:
potential_subdomain = parts[0] potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'): if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain) matched_tenant = _resolve_registered_tenant_id(potential_subdomain)
if not matched_tenant and potential_subdomain.startswith('school'): 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'): 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: if matched_tenant:
self.subdomain = potential_subdomain self.subdomain = potential_subdomain
self.tenant_id = matched_tenant self.tenant_id = matched_tenant
@@ -565,12 +574,21 @@ class TenantContext:
# Priority 4: sticky tenant from the authenticated session # Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else '' session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
if session_tenant: if session_tenant:
self.tenant_id = session_tenant matched_tenant = _resolve_registered_tenant_id(session_tenant)
self.config = get_tenant_config(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( 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. # Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB. # If no explicit 'default' tenant config exists, use configured MongoDB DB.
+308 -89
View File
@@ -12,6 +12,250 @@ fi
# Resolve script directory so config paths are deterministic even when called via sudo # Resolve script directory so config paths are deterministic even when called via sudo
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json" 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() { ensure_runtime_config_json() {
local config_path backup_path local config_path backup_path
@@ -201,65 +445,35 @@ sys.path.insert(0, "/app/Web")
from Web.modules.database import settings from Web.modules.database import settings
from pymongo import MongoClient from pymongo import MongoClient
import Web.modules.inventarsystem.data_protection as dp import Web.modules.inventarsystem.data_protection as dp
import Web.modules.database.user as us
tenant_id = sys.argv[1].lower() tenant_id = sys.argv[1].lower()
mode = sys.argv[2] mode = sys.argv[2]
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_") sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}" db_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
db = client[db_name] db = client[db_name]
users = db["users"]
pw_bytes = "admin123".encode("utf-8") permission_defaults = us.build_default_permission_payload("full_access")
random_salt = os.urandom(16) admin_doc = {
hashed = hashlib.scrypt(pw_bytes, salt=random_salt, n=16384, r=8, p=1) "Username": "admin",
hashed_pw_string = f"v1${random_salt.hex()}${hashed.hex()}" "Password": us.hashing("admin123"),
"Admin": True,
action_permissions = { "active_ausleihung": None,
"can_borrow": True, "name": dp.encrypt_text("admin"),
"can_insert": True, "last_name": dp.encrypt_text("admin"),
"can_edit": True, "IsStudent": False,
"can_delete": True, "PermissionPreset": permission_defaults["preset"],
"can_manage_users": True, "ActionPermissions": permission_defaults["actions"],
"can_manage_settings": True, "PagePermissions": permission_defaults["pages"],
"can_view_logs": True,
} }
users.replace_one({"Username": "admin"}, admin_doc, upsert=True)
page_permissions = { print("Fallback successfully applied")
"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,
})
if mode == "trial": if mode == "trial":
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
db = client[db_name]
db.settings.update_one( db.settings.update_one(
{"setting_type": "tenant_trial"}, {"setting_type": "tenant_trial"},
{"$set": { {"$set": {
@@ -272,6 +486,8 @@ if mode == "trial":
upsert=True, upsert=True,
) )
client.close()
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123") print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
PY PY
} }
@@ -533,15 +749,18 @@ ${YELLOW}Nutzung:${RESET} $0 <befehl> [tenant_id] [optionen]
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET} ${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
${GREEN}add${RESET} <tenant_id> [port] ${GREEN}add${RESET} <tenant_id> [port]
Legt einen neuen Tenant an, registriert den Port und initialisiert Legt einen neuen Tenant an, registriert den Port, richtet den nginx-Host
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}). 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] ${GREEN}trial${RESET} <tenant_id> [port] [tage]
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 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> ${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. Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
${GREEN}restart-tenant${RESET} <tenant_id> ${GREEN}restart-tenant${RESET} <tenant_id>
@@ -602,11 +821,16 @@ case "$COMMAND" in
register_tenant_port "$TENANT_ID" "$PORT_ARG" register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG" update_runtime_ports "$PORT_ARG"
sync_tenant_port_map 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 if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container restart_app_container
fi fi
fi fi
echo "Adding new tenant '$TENANT_ID'..." echo "Adding new tenant '$TENANT_ID'..."
echo "Initializing database for $TENANT_ID..." echo "Initializing database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "standard" initialize_tenant_database "$TENANT_ID" "standard"
@@ -631,6 +855,9 @@ case "$COMMAND" in
register_tenant_port "$TENANT_ID" "$PORT_ARG" register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG" update_runtime_ports "$PORT_ARG"
sync_tenant_port_map 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 fi
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG" write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
@@ -643,11 +870,11 @@ case "$COMMAND" in
initialize_tenant_database "$TENANT_ID" "trial" initialize_tenant_database "$TENANT_ID" "trial"
echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete." echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete."
;; ;;
remove) remove)
FORCE_REMOVE=false FORCE_REMOVE=false
TENANT_ARG="${2:-}" TENANT_ARG="${2:-}"
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
FORCE_REMOVE=true FORCE_REMOVE=true
TENANT_ID="${3:-}" TENANT_ID="${3:-}"
@@ -672,40 +899,31 @@ case "$COMMAND" in
echo "Removing tenant '$TENANT_ID'..." echo "Removing tenant '$TENANT_ID'..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
port_to_remove="" port_to_remove=""
if [ -n "$APP_CONTAINER" ]; then if [ -n "$APP_CONTAINER" ]; then
# Zuerst die Datenbank via PyMongo hart droppen (Erzwungenes Löschen) # MongoDB-Datenbank via PyMongo direkt im Container droppen
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY' docker exec -i "$APP_CONTAINER" python3 -c '
import sys, re import sys, os
sys.path.insert(0, '/app') try:
sys.path.insert(0, '/app/Web') import pymongo
from tenant import delete_tenant, get_tenant_config tenant_id = sys.argv[1]
from Web.modules.database import settings mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017/")
from pymongo import MongoClient 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] # Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln
tenant_cfg = get_tenant_config(tenant_id) if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
port = tenant_cfg.get('port') :
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." echo "Tenant '$TENANT_ID' database and config removed."
else else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB." echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
@@ -719,6 +937,7 @@ PY
if [ -n "$port_to_remove" ]; then if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove" remove_runtime_port "$port_to_remove"
fi fi
remove_tenant_nginx_config "$TENANT_ID"
sync_tenant_port_map sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container restart_app_container
@@ -728,7 +947,7 @@ PY
else else
echo "Removed tenant '$TENANT_ID'. No port mapping was present." echo "Removed tenant '$TENANT_ID'. No port mapping was present."
fi fi
;; ;;
restart-tenant) restart-tenant)
TENANT_ID="${2:-}" TENANT_ID="${2:-}"
+192 -11
View File
@@ -9,13 +9,13 @@ LOG_DIR="$PROJECT_DIR/logs"
LOG_FILE="$LOG_DIR/update.log" LOG_FILE="$LOG_DIR/update.log"
STATE_FILE="$PROJECT_DIR/.release-version" STATE_FILE="$PROJECT_DIR/.release-version"
REPO_SLUG="Invario/Inventarsystem" 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" BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
ENV_FILE="$PROJECT_DIR/.docker-build.env" ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem" APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}" MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
MODE="release" MODE="stable"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true chmod 777 "$LOG_DIR" 2>/dev/null || true
@@ -119,6 +119,7 @@ usage() {
Usage: $0 [options] Usage: $0 [options]
Options: Options:
-dev Install the latest prerelease build
--multitenant Use docker-compose-multitenant.yml (default) --multitenant Use docker-compose-multitenant.yml (default)
-h, --help Show this help message -h, --help Show this help message
EOF EOF
@@ -127,6 +128,10 @@ EOF
parse_args() { parse_args() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-dev)
MODE="development"
shift
;;
--multitenant) --multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
@@ -179,7 +184,8 @@ create_backup() {
fetch_release_metadata() { fetch_release_metadata() {
local meta_file="$1" 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() { parse_latest_tag() {
@@ -207,6 +213,124 @@ for asset in data.get('assets', []):
PY 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() { refresh_runtime_scripts_from_main() {
local start_url stop_url restart_url update_url local start_url stop_url restart_url update_url
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh" start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
@@ -368,12 +492,66 @@ main() {
archive_logs archive_logs
create_backup 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 if [ "$MODE" = "development" ]; then
log_message "Requested development install" log_message "Requested development install"
local tag="dev" local tag meta_file releases_file stable_meta_file stable_tag prerelease_tag prerelease_base_tag bundle_url app_image compose_path
local app_image="$APP_IMAGE_REPO:$tag" local tmp_dir
local compose_path="$PROJECT_DIR/$COMPOSE_FILE"
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 if [ ! -f "$compose_path" ]; then
log_message "ERROR: compose file not found: $compose_path" log_message "ERROR: compose file not found: $compose_path"
@@ -399,6 +577,9 @@ EOF
exit 1 exit 1
fi fi
download_and_extract_bundle "$bundle_url" "$tmp_dir"
refresh_runtime_scripts_from_main
# Bring up stack # 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" 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 docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
@@ -409,7 +590,7 @@ EOF
fi fi
echo "$tag" > "$STATE_FILE" echo "$tag" > "$STATE_FILE"
log_message "Development update completed successfully" log_message "Development update completed successfully to prerelease $tag"
exit 0 exit 0
fi fi
@@ -419,8 +600,8 @@ EOF
trap 'rm -rf "${tmp_dir:-}"' EXIT trap 'rm -rf "${tmp_dir:-}"' EXIT
log_message "Checking latest Gitea release for $REPO_SLUG..." log_message "Checking latest stable Gitea release for $REPO_SLUG..."
if ! fetch_release_metadata "$meta_file"; then if ! fetch_release_metadata "$meta_file" "latest"; then
log_message "WARNING: Could not fetch release metadata. Falling back to self-healing start path." 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 INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then
if verify_stack_health; then if verify_stack_health; then
@@ -436,7 +617,7 @@ EOF
exit 0 exit 0
fi fi
latest_tag="$(parse_latest_tag "$meta_file")" latest_tag="$(parse_release_tag "$meta_file")"
if [ -z "$latest_tag" ]; then if [ -z "$latest_tag" ]; then
log_message "WARNING: Could not determine latest release tag. Falling back to self-healing start path." 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 if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then