Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f9fae10af | |||
| f45988d0e7 | |||
| 32f39223f4 | |||
| 20528b60c5 | |||
| c610c06a0e | |||
| 2cef1672d2 | |||
| eb8542c410 | |||
| 1ebd83ec2c | |||
| e80bf946c0 | |||
| bfbbff597e | |||
| d80f3f56b7 | |||
| 726fca0866 | |||
| 614f73250a | |||
| 0d2488eb0b | |||
| 7e6a1d6b03 | |||
| ebf144f1a9 | |||
| 78fda8d569 | |||
| 248594ce0d | |||
| 783c68a243 | |||
| 031d41643e | |||
| cae3199e53 | |||
| d0f2afae57 | |||
| 1a99448a2e | |||
| aec25a13b9 | |||
| b31abe2177 | |||
| 7fd27e8c02 | |||
| 5f5784e7c3 | |||
| 613c1e11e4 | |||
| 9e3b54c1b1 | |||
| a8bce26eab | |||
| 36c391e99c | |||
| 40c3970719 | |||
| e7f4b1ce9d | |||
| d84466a3cc | |||
| 0efc01a835 | |||
| 73cf63a56d | |||
| fdcd26326b | |||
| 1fd9e230ac | |||
| cc0c4e28b7 | |||
| ea73b98549 | |||
| d6dda0f25a | |||
| 46176c1741 | |||
| c57b4a9a44 | |||
| 6f424cc8aa |
@@ -1,11 +0,0 @@
|
|||||||
NUITKA_BUILD=0
|
|
||||||
INVENTAR_HTTP_PORT=10000
|
|
||||||
INVENTAR_HTTP_PORTS=10000
|
|
||||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
|
|
||||||
INVENTAR_APP_CPUS=1.0
|
|
||||||
INVENTAR_APP_MEM_LIMIT=384m
|
|
||||||
INVENTAR_APP_MEM_SWAP_LIMIT=768m
|
|
||||||
INVENTAR_WORKERS=4
|
|
||||||
INVENTAR_THREADS=2
|
|
||||||
INVENTAR_WORKER_TIMEOUT=30
|
|
||||||
INVENTAR_WORKER_CONNECTIONS=100
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: https://github.com/AIIrondev/legendary-octo-garbanzo
|
name: Release Inventarsystem
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -25,6 +25,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-docker:
|
release-docker:
|
||||||
|
# Hinweis: Falls dein lokaler Gitea-Runner ein anderes Label hat (z.B. 'linux' oder 'self-hosted'), passe dies hier an.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -34,18 +35,20 @@ jobs:
|
|||||||
- name: Set metadata
|
- name: Set metadata
|
||||||
id: meta
|
id: meta
|
||||||
env:
|
env:
|
||||||
|
# Gitea stellt das GITHUB_TOKEN für Kompatibilität mit GitHub Actions automatisch zur Verfügung
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ gitea.event_name }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
BUMP_TYPE: ${{ gitea.event.inputs.bump || 'patch' }}
|
||||||
|
DOCKER_API_VERSION: '1.44'
|
||||||
run: |
|
run: |
|
||||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||||
TAG="$REF_NAME"
|
TAG="$REF_NAME"
|
||||||
else
|
else
|
||||||
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
|
# Gitea API Endpunkt nutzen, um das aktuellste Release abzufragen
|
||||||
latest_tag="v0.8.31"
|
latest_tag="v0.8.31"
|
||||||
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
|
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)
|
||||||
if [ -n "$tag_name" ]; then
|
if [ -n "$tag_name" ]; then
|
||||||
latest_tag="$tag_name"
|
latest_tag="$tag_name"
|
||||||
@@ -108,19 +111,58 @@ jobs:
|
|||||||
TAG="${base}.${i}"
|
TAG="${base}.${i}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
# Docker Images verlangen Kleinbuchstaben. Repository-Namen daher umwandeln.
|
||||||
|
LOWER_REPO=$(echo "$REPO" | tr '[:upper:]' '[:lower:]')
|
||||||
|
IMAGE="git.invario-software.eu/${LOWER_REPO}:${TAG}"
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
|
- name: Ensure Docker CLI is available and up to date
|
||||||
|
run: |
|
||||||
|
install_docker=true
|
||||||
|
|
||||||
|
# Prüfen, ob Docker existiert und ob die Version ausreicht
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
# Extrahiere die Major-Version (z. B. "20" aus "20.10.24" oder "26" aus "26.1.0")
|
||||||
|
DOCKER_MAJOR=$(docker --version | grep -oE '[0-9]+' | head -n1)
|
||||||
|
|
||||||
|
# API 1.44 erfordert mindestens Docker v25
|
||||||
|
if [ -n "$DOCKER_MAJOR" ] && [ "$DOCKER_MAJOR" -ge 25 ]; then
|
||||||
|
install_docker=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$install_docker" = true ]; then
|
||||||
|
echo "Veraltete oder fehlende Docker-Installation erkannt. Führe Update durch..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y curl
|
||||||
|
# Nutzt das offizielle Docker-Skript (installiert docker-ce statt das alte docker.io)
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk update
|
||||||
|
apk add --no-cache docker-cli
|
||||||
|
else
|
||||||
|
echo "Error: no supported package manager found to install docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Docker CLI ist bereits auf einem aktuellen Stand."
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker --version
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: git.invario-software.eu
|
||||||
username: ${{ github.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push release image
|
- name: Build and push release image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -128,10 +170,9 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
|
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.meta.outputs.image }}
|
${{ steps.meta.outputs.image }}
|
||||||
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
|
||||||
|
|
||||||
- name: Build local image tar for offline deploy
|
- name: Build local image tar for offline deploy
|
||||||
run: |
|
run: |
|
||||||
@@ -139,20 +180,10 @@ jobs:
|
|||||||
IMG="${{ steps.meta.outputs.image }}"
|
IMG="${{ steps.meta.outputs.image }}"
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
|
||||||
if docker image inspect "$IMG" >/dev/null 2>&1; then
|
echo "Pulling freshly pushed image from registry: $IMG"
|
||||||
echo "Using local image $IMG"
|
docker pull "$IMG"
|
||||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Local image $IMG not found, trying to pull"
|
echo "Saving image to offline tar archive..."
|
||||||
if docker pull "$IMG" >/dev/null 2>&1; then
|
|
||||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Pull failed, attempting local docker build as fallback"
|
|
||||||
docker build -t "$IMG" .
|
|
||||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||||
|
|
||||||
- name: Create release-only docker bundle
|
- name: Create release-only docker bundle
|
||||||
@@ -161,7 +192,7 @@ jobs:
|
|||||||
cat > release-bundle/docker-compose.yml <<EOF
|
cat > release-bundle/docker-compose.yml <<EOF
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
image: \${INVENTAR_APP_IMAGE:-${{ steps.meta.outputs.image }}}
|
||||||
container_name: inventarsystem-app
|
container_name: inventarsystem-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -225,7 +256,7 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Copy runtime scripts and config if present
|
# Copy runtime scripts and config if present
|
||||||
for f in start.sh stop.sh restart.sh backup.sh config.json update.sh; do
|
for f in start.sh stop.sh restart.sh backup.sh restore.sh config.json update.sh; do
|
||||||
if [ -f "$f" ]; then
|
if [ -f "$f" ]; then
|
||||||
cp "$f" "release-bundle/$(basename "$f")"
|
cp "$f" "release-bundle/$(basename "$f")"
|
||||||
fi
|
fi
|
||||||
@@ -242,12 +273,10 @@ jobs:
|
|||||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||||
|
|
||||||
- name: Create or update GitHub Release
|
- name: Create or update Gitea Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.meta.outputs.tag }}
|
tag_name: ${{ steps.meta.outputs.tag }}
|
||||||
files: |
|
files: |
|
||||||
inventarsystem-docker-bundle.tar.gz
|
inventarsystem-docker-bundle.tar.gz
|
||||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||||
fail_on_unmatched_files: false
|
|
||||||
generate_release_notes: true
|
|
||||||
@@ -120,19 +120,19 @@ Das Inventarsystem stellt folgende Wartungsskripte bereit:
|
|||||||
**Option 1**
|
**Option 1**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget -O - https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | sudo bash
|
wget -O - https://git.invario-software.eu/Invario/Inventarsystem/raw/branch/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option 2**
|
**Option 2**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | sudo bash
|
curl -s https://git.invario-software.eu/Invario/Inventarsystem/raw/branch/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Legacy-MongoDB uebernehmen und altes Host-System aufraeumen:
|
Legacy-MongoDB uebernehmen und altes Host-System aufraeumen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | \
|
curl -s https://git.invario-software.eu/Invario/Inventarsystem/raw/branch/main/install.sh | \
|
||||||
sudo bash -s -- --migrate-legacy-db --remove-legacy-system
|
sudo bash -s -- --migrate-legacy-db --remove-legacy-system
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+108
-19
@@ -42,6 +42,7 @@ import Web.modules.database.termine as termin
|
|||||||
import Web.modules.log.audit_log as al
|
import Web.modules.log.audit_log as al
|
||||||
import push_notifications as pn
|
import push_notifications as pn
|
||||||
import Web.modules.inventarsystem.pdf_export as pdf_export
|
import Web.modules.inventarsystem.pdf_export as pdf_export
|
||||||
|
import Web.modules.inventarsystem.excel_export as excel_export
|
||||||
import datetime
|
import datetime
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
@@ -288,7 +289,7 @@ SCHEDULER_INTERVAL = cfg.SCHEDULER_INTERVAL_MIN
|
|||||||
SSL_CERT = cfg.SSL_CERT
|
SSL_CERT = cfg.SSL_CERT
|
||||||
SSL_KEY = cfg.SSL_KEY
|
SSL_KEY = cfg.SSL_KEY
|
||||||
|
|
||||||
LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media', 'schulbuch']
|
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'other', 'schoolbook', 'Buch', 'Schulbuch', 'schulbuch')
|
||||||
INVOICE_CURRENCY = 'EUR'
|
INVOICE_CURRENCY = 'EUR'
|
||||||
|
|
||||||
NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8')))
|
NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8')))
|
||||||
@@ -2476,6 +2477,7 @@ def _upload_excel_items(scope='inventory'):
|
|||||||
parent_item_id=parent_item_id,
|
parent_item_id=parent_item_id,
|
||||||
isbn=row['isbn'],
|
isbn=row['isbn'],
|
||||||
item_type=row['item_type'],
|
item_type=row['item_type'],
|
||||||
|
is_library=False
|
||||||
)
|
)
|
||||||
if not item_id:
|
if not item_id:
|
||||||
import_errors.append((row['row_number'], 'Fehler beim Anlegen des Artikels'))
|
import_errors.append((row['row_number'], 'Fehler beim Anlegen des Artikels'))
|
||||||
@@ -2979,33 +2981,50 @@ def library_export_excel(scope='all'):
|
|||||||
username = session['username']
|
username = session['username']
|
||||||
is_admin_user = us.check_admin(username)
|
is_admin_user = us.check_admin(username)
|
||||||
|
|
||||||
import Web.modules.inventarsystem.excel_export as excel_export
|
# Base query
|
||||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
query = {
|
||||||
db = client[cfg.MONGODB_DB]
|
'ItemType': {'$in': LIBRARY_ITEM_TYPES},
|
||||||
items_collection = db['items']
|
'Deleted': {'$ne': True}
|
||||||
|
}
|
||||||
query = {'ItemType': {'$in': LIBRARY_ITEM_TYPES}, 'Deleted': {'$ne': True}}
|
|
||||||
|
|
||||||
|
# Scope modifications
|
||||||
if scope == 'borrowed_by_me':
|
if scope == 'borrowed_by_me':
|
||||||
query['User'] = username
|
query['User'] = username
|
||||||
query['Verfuegbar'] = False
|
query['Verfuegbar'] = False
|
||||||
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
|
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
|
||||||
|
|
||||||
elif scope == 'all_borrowed':
|
elif scope == 'all_borrowed':
|
||||||
if not is_admin_user:
|
if not is_admin_user:
|
||||||
|
# Consider adding a flash message here before redirecting
|
||||||
return redirect('/library')
|
return redirect('/library')
|
||||||
query['Verfuegbar'] = False
|
query['Verfuegbar'] = False
|
||||||
filename = "Bibliothek_Alle_Ausleihen.xlsx"
|
filename = "Bibliothek_Alle_Ausleihen.xlsx"
|
||||||
|
|
||||||
elif scope == 'schulbuecher':
|
elif scope == 'schulbuecher':
|
||||||
|
# Overwrites the base $in query, which is fine
|
||||||
query['ItemType'] = 'schulbuch'
|
query['ItemType'] = 'schulbuch'
|
||||||
filename = "Schulbuecher_Bestand.xlsx"
|
filename = "Schulbuecher_Bestand.xlsx"
|
||||||
|
|
||||||
|
elif scope == 'all_books':
|
||||||
|
# FIX: Keep the library item types restriction, but exclude 'general'
|
||||||
|
query['ItemType'] = {'$in': [t for t in LIBRARY_ITEM_TYPES if t != 'general']}
|
||||||
|
filename = "Bibliothek_Buecher.xlsx"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 'all'
|
# 'all' scope
|
||||||
filename = "Bibliothek_Gesamtbestand.xlsx"
|
filename = "Bibliothek_Gesamtbestand.xlsx"
|
||||||
|
|
||||||
|
# Database interaction
|
||||||
|
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||||
|
db = client[cfg.MONGODB_DB]
|
||||||
|
items_collection = db['items']
|
||||||
|
|
||||||
items = list(items_collection.find(query))
|
items = list(items_collection.find(query))
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
# Generate and send file
|
||||||
excel_file = excel_export.generate_library_excel(items)
|
excel_file = excel_export.generate_library_excel(items)
|
||||||
|
|
||||||
return send_file(
|
return send_file(
|
||||||
excel_file,
|
excel_file,
|
||||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
@@ -3193,7 +3212,7 @@ def api_library_items():
|
|||||||
ausleihungen_db = db['ausleihungen']
|
ausleihungen_db = db['ausleihungen']
|
||||||
|
|
||||||
query = {
|
query = {
|
||||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
|
'ItemType': {'$in': ['book', 'cd', 'dvd', 'other', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']},
|
||||||
'IsGroupedSubItem': {'$ne': True},
|
'IsGroupedSubItem': {'$ne': True},
|
||||||
'Deleted': {'$ne': True}
|
'Deleted': {'$ne': True}
|
||||||
}
|
}
|
||||||
@@ -5115,6 +5134,8 @@ def upload_item():
|
|||||||
if 'username' not in session:
|
if 'username' not in session:
|
||||||
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
|
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
|
||||||
|
|
||||||
|
item_is_library = False
|
||||||
|
|
||||||
# Check if user may insert items
|
# Check if user may insert items
|
||||||
username = session['username']
|
username = session['username']
|
||||||
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
|
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
|
||||||
@@ -5159,6 +5180,9 @@ def upload_item():
|
|||||||
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
|
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
|
||||||
library_category = sanitize_form_value(request.form.get('library_category', ''))
|
library_category = sanitize_form_value(request.form.get('library_category', ''))
|
||||||
|
|
||||||
|
if library_category != '':
|
||||||
|
item_is_library = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item_count = int(item_count_raw) if item_count_raw else 1
|
item_count = int(item_count_raw) if item_count_raw else 1
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -5227,7 +5251,7 @@ def upload_item():
|
|||||||
return redirect(url_for(success_redirect_endpoint))
|
return redirect(url_for(success_redirect_endpoint))
|
||||||
|
|
||||||
item_isbn = ''
|
item_isbn = ''
|
||||||
item_type = 'general'
|
item_type = 'other'
|
||||||
if cfg.MODULES.is_enabled('library') and isbn_raw:
|
if cfg.MODULES.is_enabled('library') and isbn_raw:
|
||||||
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
|
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
|
||||||
if normalized_isbn:
|
if normalized_isbn:
|
||||||
@@ -5235,8 +5259,7 @@ def upload_item():
|
|||||||
item_type = 'book'
|
item_type = 'book'
|
||||||
else:
|
else:
|
||||||
item_isbn = isbn_raw
|
item_isbn = isbn_raw
|
||||||
if upload_mode == 'library':
|
|
||||||
item_type = 'book'
|
|
||||||
if item_type_input != "":
|
if item_type_input != "":
|
||||||
item_type = item_type_input
|
item_type = item_type_input
|
||||||
|
|
||||||
@@ -5253,7 +5276,6 @@ def upload_item():
|
|||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
return jsonify({'success': False, 'message': error_msg}), 400
|
||||||
flash(error_msg, 'error')
|
flash(error_msg, 'error')
|
||||||
return redirect(url_for('library_admin'))
|
return redirect(url_for('library_admin'))
|
||||||
item_type = 'book'
|
|
||||||
|
|
||||||
# Only check for images if not duplicating and no duplicate images provided and no book cover
|
# Only check for images if not duplicating and no duplicate images provided and no book cover
|
||||||
# For library mode, skip this check as images come only from ISBN fetch
|
# For library mode, skip this check as images come only from ISBN fetch
|
||||||
@@ -6047,7 +6069,8 @@ def upload_item():
|
|||||||
parent_item_id=parent_item_id,
|
parent_item_id=parent_item_id,
|
||||||
isbn=item_isbn,
|
isbn=item_isbn,
|
||||||
item_type=item_type,
|
item_type=item_type,
|
||||||
library_category=library_category
|
library_category=library_category,
|
||||||
|
is_library=item_is_library
|
||||||
)
|
)
|
||||||
if not item_id:
|
if not item_id:
|
||||||
break
|
break
|
||||||
@@ -6601,23 +6624,89 @@ def edit_item(id):
|
|||||||
if ort and ort not in predefined_locations:
|
if ort and ort not in predefined_locations:
|
||||||
it.add_predefined_location(ort)
|
it.add_predefined_location(ort)
|
||||||
|
|
||||||
# Update the item
|
|
||||||
result = it.update_item(
|
result = it.update_item(
|
||||||
id, name, ort, beschreibung,
|
id=id,
|
||||||
images, verfuegbar, filter1, filter2, filter3,
|
name=name,
|
||||||
anschaffungs_jahr, anschaffungs_kosten, code_4, reservierbar,
|
ort=ort,
|
||||||
|
beschreibung=beschreibung,
|
||||||
|
images=images,
|
||||||
|
verfuegbar=verfuegbar,
|
||||||
|
filter1=filter1,
|
||||||
|
filter2=filter2,
|
||||||
|
filter3=filter3,
|
||||||
|
ansch_jahr=anschaffungs_jahr,
|
||||||
|
ansch_kost=anschaffungs_kosten,
|
||||||
|
code_4=code_4,
|
||||||
|
reservierbar=reservierbar,
|
||||||
isbn=item_isbn,
|
isbn=item_isbn,
|
||||||
item_type=item_type
|
item_type=item_type
|
||||||
)
|
)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
flash('Element erfolgreich aktualisiert', 'success')
|
flash('Element erfolgreich aktualisiert (und ggf. Gruppe synchronisiert)', 'success')
|
||||||
else:
|
else:
|
||||||
flash('Fehler beim Aktualisieren des Elements', 'error')
|
flash('Fehler beim Aktualisieren des Elements', 'error')
|
||||||
|
|
||||||
return redirect(url_for('home_admin'))
|
return redirect(url_for('home_admin'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/update_group', methods=['POST'])
|
||||||
|
def update_group():
|
||||||
|
data = request.get_json()
|
||||||
|
series_group_id = data.get('series_group_id')
|
||||||
|
|
||||||
|
if not series_group_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Keine Gruppen-ID'}), 400
|
||||||
|
|
||||||
|
# 1. Shared Fields (Group Logic)
|
||||||
|
# These apply to every item in the group
|
||||||
|
shared_update = {
|
||||||
|
'Name': data.get('name'),
|
||||||
|
'Ort': data.get('ort'),
|
||||||
|
'Beschreibung': data.get('beschreibung'),
|
||||||
|
'Anschaffungsjahr': data.get('ansch_jahr'),
|
||||||
|
'Anschaffungskosten': data.get('ansch_kost'),
|
||||||
|
'Reservierbar': data.get('reservierbar'),
|
||||||
|
'ISBN': data.get('isbn'),
|
||||||
|
'ItemType': data.get('item_type'),
|
||||||
|
'LastUpdated': datetime.datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Individual Updates (Specific Code Logic)
|
||||||
|
# Expected format: [{'id': '...', 'code_4': '...'}, ...]
|
||||||
|
individual_items = data.get('items', [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = client[cfg.MONGODB_DB]
|
||||||
|
items_col = db['items']
|
||||||
|
|
||||||
|
# A. Apply Shared Attributes to the whole group
|
||||||
|
items_col.update_many(
|
||||||
|
{'SeriesGroupId': series_group_id},
|
||||||
|
{'$set': shared_update}
|
||||||
|
)
|
||||||
|
|
||||||
|
# B. Apply Unique Codes to specific items
|
||||||
|
# We iterate through the provided list to update the specific code for each ID
|
||||||
|
for item in individual_items:
|
||||||
|
item_id = item.get('id')
|
||||||
|
new_code = item.get('code_4')
|
||||||
|
|
||||||
|
if item_id:
|
||||||
|
items_col.update_one(
|
||||||
|
{'_id': ObjectId(item_id), 'SeriesGroupId': series_group_id},
|
||||||
|
{'$set': {'Code_4': new_code}}
|
||||||
|
)
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
return jsonify({'success': True, 'message': 'Gruppe und individuelle Codes aktualisiert'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error updating group {series_group_id}: {e}")
|
||||||
|
return jsonify({'success': False, 'message': 'Systemfehler beim Update'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/report_damage/<id>', methods=['POST'])
|
@app.route('/report_damage/<id>', methods=['POST'])
|
||||||
def report_damage(id):
|
def report_damage(id):
|
||||||
"""Register a damage report entry for an item (admin only)."""
|
"""Register a damage report entry for an item (admin only)."""
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import Web.modules.database.settings as cfg
|
|||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
|
|
||||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media', 'schulbuch')
|
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'other', 'schoolbook', 'Buch', 'Schulbuch', 'schulbuch')
|
||||||
|
|
||||||
|
|
||||||
def _non_library_query(extra_query=None):
|
def _non_library_query(extra_query=None):
|
||||||
@@ -49,7 +49,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
|
|||||||
ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True,
|
ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True,
|
||||||
series_group_id=None, series_count=1, series_position=1,
|
series_group_id=None, series_count=1, series_position=1,
|
||||||
is_grouped_sub_item=False, parent_item_id=None,
|
is_grouped_sub_item=False, parent_item_id=None,
|
||||||
isbn=None, item_type='general', library_category=None):
|
isbn=None, item_type='general', library_category=None, is_library=False):
|
||||||
"""
|
"""
|
||||||
Add a new item to the inventory.
|
Add a new item to the inventory.
|
||||||
|
|
||||||
@@ -102,6 +102,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
|
|||||||
'ISBN': isbn,
|
'ISBN': isbn,
|
||||||
'library_category': library_category,
|
'library_category': library_category,
|
||||||
'ItemType': item_type,
|
'ItemType': item_type,
|
||||||
|
'is_library': is_library,
|
||||||
'SeriesGroupId': series_group_id,
|
'SeriesGroupId': series_group_id,
|
||||||
'SeriesCount': series_count,
|
'SeriesCount': series_count,
|
||||||
'SeriesPosition': series_position,
|
'SeriesPosition': series_position,
|
||||||
@@ -198,70 +199,61 @@ def get_group_item_ids(id):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def update_item(id, name, ort, beschreibung, images=None, verfuegbar=True,
|
def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3,
|
||||||
filter=None, filter2=None, filter3=None, ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True,
|
ansch_jahr, ansch_kost, code_4, reservierbar, isbn=None, item_type='general'):
|
||||||
isbn=None, item_type='general', library_category=None):
|
|
||||||
"""
|
|
||||||
Update an existing inventory item.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
id (str): ID of the item to update
|
|
||||||
name (str): Name of the item
|
|
||||||
ort (str): Location of the item
|
|
||||||
beschreibung (str): Description of the item
|
|
||||||
images (list, optional): List of image filenames for the item
|
|
||||||
verfuegbar (bool, optional): Availability status of the item
|
|
||||||
filter (str, optional): Primary filter/category for the item
|
|
||||||
filter2 (str, optional): Secondary filter/category for the item
|
|
||||||
filter3 (str, optional): Tertiary filter/category for the item
|
|
||||||
ansch_jahr (int, optional): Year of acquisition
|
|
||||||
ansch_kost (float, optional): Cost of acquisition
|
|
||||||
code_4 (str, optional): 4-digit identification code
|
|
||||||
reservierbar (bool, optional): Whether the item can be reserved in advance
|
|
||||||
library_category (str, optional): Library category for the item
|
|
||||||
Returns:
|
|
||||||
bool: True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
items = db['items']
|
items = db['items']
|
||||||
|
|
||||||
# Set default values for optional parameters
|
# 1. Altes Item laden, um SeriesGroupId zu bestimmen
|
||||||
if images is None:
|
old_item = items.find_one({'_id': ObjectId(id)})
|
||||||
images = []
|
if not old_item:
|
||||||
|
return False
|
||||||
|
|
||||||
|
series_group_id = old_item.get('SeriesGroupId')
|
||||||
|
|
||||||
update_data = {
|
# 2. Shared Data: Daten, die für ALLE in der Gruppe gleich sind
|
||||||
|
shared_update = {
|
||||||
'Name': name,
|
'Name': name,
|
||||||
'Ort': ort,
|
'Ort': ort,
|
||||||
'Beschreibung': beschreibung,
|
'Beschreibung': beschreibung,
|
||||||
'Images': images,
|
'Images': images,
|
||||||
'Verfuegbar': verfuegbar,
|
'Filter': filter1,
|
||||||
'Reservierbar': reservierbar,
|
|
||||||
'Filter': filter,
|
|
||||||
'Filter2': filter2,
|
'Filter2': filter2,
|
||||||
'Filter3': filter3,
|
'Filter3': filter3,
|
||||||
'Anschaffungsjahr': ansch_jahr,
|
'Anschaffungsjahr': ansch_jahr,
|
||||||
'Anschaffungskosten': ansch_kost,
|
'Anschaffungskosten': ansch_kost,
|
||||||
'Code_4': code_4,
|
'Reservierbar': reservierbar,
|
||||||
'ISBN': isbn,
|
'ISBN': isbn,
|
||||||
'library_category': library_category,
|
|
||||||
'ItemType': item_type,
|
'ItemType': item_type,
|
||||||
|
'Verfuegbar': verfuegbar, # Wir behalten den Status bei
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
result = items.update_one(
|
# 3. Spezifische Daten: Was NICHT synchronisiert wird
|
||||||
{'_id': ObjectId(id)},
|
specific_update = shared_update.copy()
|
||||||
{'$set': update_data}
|
specific_update['Code_4'] = code_4
|
||||||
)
|
|
||||||
|
# 4. Das aktuelle Item updaten
|
||||||
|
items.update_one({'_id': ObjectId(id)}, {'$set': specific_update})
|
||||||
|
|
||||||
|
# 5. Alle anderen Gruppen-Mitglieder synchronisieren
|
||||||
|
if series_group_id:
|
||||||
|
items.update_many(
|
||||||
|
{
|
||||||
|
'SeriesGroupId': series_group_id,
|
||||||
|
'_id': {'$ne': ObjectId(id)}
|
||||||
|
},
|
||||||
|
{'$set': shared_update}
|
||||||
|
)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
return result.modified_count > 0
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating item: {e}")
|
print(f"Error updating item: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_item_status(id, verfuegbar, user=None):
|
def update_item_status(id, verfuegbar, user=None):
|
||||||
"""
|
"""
|
||||||
Update the availability status of an inventory item.
|
Update the availability status of an inventory item.
|
||||||
|
|||||||
@@ -37,20 +37,23 @@ def generate_library_excel(items):
|
|||||||
item.get("ReturnDate", ""),
|
item.get("ReturnDate", ""),
|
||||||
item.get("Anschaffungskosten", "")
|
item.get("Anschaffungskosten", "")
|
||||||
]
|
]
|
||||||
ws.append(row)
|
|
||||||
|
|
||||||
for col in ws.columns:
|
# FIX: Clean the row data INSIDE the loop for every single item
|
||||||
max_length = 0
|
clean_row = []
|
||||||
column = col[0].column_letter
|
for value in row:
|
||||||
for cell in col:
|
if isinstance(value, list):
|
||||||
try:
|
# Converts ['A', 'B'] to "A, B" and an empty list [] to an empty string ""
|
||||||
if len(str(cell.value)) > max_length:
|
clean_row.append(", ".join(map(str, value)) if value else "")
|
||||||
max_length = len(str(cell.value))
|
elif isinstance(value, dict):
|
||||||
except:
|
# Just in case there is an embedded MongoDB sub-document
|
||||||
pass
|
clean_row.append(str(value))
|
||||||
ws.column_dimensions[column].width = min(max_length + 2, 50)
|
else:
|
||||||
|
clean_row.append(value)
|
||||||
|
|
||||||
|
# Append the safe, flattened row to the worksheet
|
||||||
|
ws.append(clean_row)
|
||||||
|
|
||||||
excel_buffer = io.BytesIO()
|
excel_buffer = io.BytesIO()
|
||||||
wb.save(excel_buffer)
|
wb.save(excel_buffer)
|
||||||
excel_buffer.seek(0)
|
excel_buffer.seek(0)
|
||||||
return excel_buffer
|
return excel_buffer
|
||||||
@@ -4,6 +4,57 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
|
/* The Dark Background Overlay */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
/* Display is managed by JS (none/flex) */
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The White Modal Box */
|
||||||
|
.modal-content {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
width: 80%;
|
||||||
|
max-width: 600px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
/* Flexbox allows the header to stay fixed while the body scrolls */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Close Button */
|
||||||
|
.close {
|
||||||
|
align-self: flex-end;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover,
|
||||||
|
.close:focus {
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Scrollable Content Area */
|
||||||
|
#detailContent {
|
||||||
|
overflow-y: auto; /* Adds scrollbar only if needed */
|
||||||
|
padding-right: 10px; /* Prevents text from rubbing against the scrollbar */
|
||||||
|
}
|
||||||
/* Library table-only view styles */
|
/* Library table-only view styles */
|
||||||
.library-table-container {
|
.library-table-container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
@@ -475,7 +526,7 @@
|
|||||||
<select id="filterType">
|
<select id="filterType">
|
||||||
<option value="">-- Alle Typen --</option>
|
<option value="">-- Alle Typen --</option>
|
||||||
<option value="book">Buch</option>
|
<option value="book">Buch</option>
|
||||||
<option value="schulbuch">Schulbuch</option>
|
<option value="schoolbook">Schulbuch</option>
|
||||||
<option value="cd">CD</option>
|
<option value="cd">CD</option>
|
||||||
<option value="dvd">DVD</option>
|
<option value="dvd">DVD</option>
|
||||||
<option value="other">Sonstige</option>
|
<option value="other">Sonstige</option>
|
||||||
@@ -503,6 +554,10 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||||
Alle ausgeliehen Excel
|
Alle ausgeliehen Excel
|
||||||
</button>
|
</button>
|
||||||
|
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/all_books'">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||||
|
Alle Bücher Excel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -536,15 +591,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detail Modal -->
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
|
||||||
|
|
||||||
<div id="detailModal" class="modal" style="display: none;">
|
<div id="detailModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close" onclick="closeDetailModal()">×</span>
|
<span class="close" onclick="closeDetailModal()">×</span>
|
||||||
<div id="detailContent"></div>
|
<div id="detailContent"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -1033,7 +1087,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getItemTypeLabel(type) {
|
function getItemTypeLabel(type) {
|
||||||
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' };
|
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige', 'schoolbook': 'Schulbuch' };
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,20 +1097,47 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opens the modal and fetches the data
|
||||||
function showItemDetail(itemId) {
|
function showItemDetail(itemId) {
|
||||||
|
const detailContent = document.getElementById('detailContent');
|
||||||
|
const detailModal = document.getElementById('detailModal');
|
||||||
|
|
||||||
|
// 1. Show the loading state immediately
|
||||||
|
detailContent.innerHTML = '<p>Loading details...</p>';
|
||||||
|
detailModal.style.display = 'flex';
|
||||||
|
|
||||||
|
// 2. Fetch the data
|
||||||
fetch(`/api/item_detail/${itemId}`)
|
fetch(`/api/item_detail/${itemId}`)
|
||||||
.then(r => r.text())
|
.then(response => {
|
||||||
.then(html => {
|
if (!response.ok) {
|
||||||
document.getElementById('detailContent').innerHTML = html;
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
document.getElementById('detailModal').style.display = 'flex';
|
}
|
||||||
|
return response.text();
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Error loading detail:', err));
|
.then(html => {
|
||||||
|
// 3. Clean the HTML and display it
|
||||||
|
detailContent.innerHTML = DOMPurify.sanitize(html);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error loading detail:', err);
|
||||||
|
detailContent.innerHTML = '<p>Sorry, we could not load the item details. Please try again later.</p>';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Closes the modal via the 'x' button
|
||||||
function closeDetailModal() {
|
function closeDetailModal() {
|
||||||
document.getElementById('detailModal').style.display = 'none';
|
document.getElementById('detailModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Closes the modal if the user clicks the dark background overlay
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('detailModal');
|
||||||
|
// If the click happened directly on the dark overlay (not the white box)
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeDetailModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -1192,75 +1273,194 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal Control Functions (accessible globally from table buttons)
|
window.openEditLibraryItem = function(itemId) {
|
||||||
function openEditLibraryItem(itemId) {
|
|
||||||
const item = libraryItems.find(i => i._id === itemId);
|
const item = libraryItems.find(i => i._id === itemId);
|
||||||
|
if (!item) return;
|
||||||
if (!item) {
|
|
||||||
alert('Fehler: Das Medium konnte nicht gefunden werden.');
|
// 1. Felder befüllen
|
||||||
return;
|
document.getElementById('editLibraryItemId').value = item._id;
|
||||||
|
document.getElementById('editLibraryName').value = item.Name;
|
||||||
|
document.getElementById('editLibraryType').value = item.ItemType;
|
||||||
|
document.getElementById('editLibraryIsbn').value = item.ISBN || '';
|
||||||
|
document.getElementById('editLibraryCode4').value = item.Code_4 || '';
|
||||||
|
document.getElementById('editLibraryLocation').value = item.Ort;
|
||||||
|
document.getElementById('editLibraryDescription').value = item.Beschreibung;
|
||||||
|
|
||||||
|
// 2. Gruppen-Logik
|
||||||
|
const warningDiv = document.getElementById('editLibraryGroupWarning');
|
||||||
|
const codesContainer = document.getElementById('editLibraryAllCodes');
|
||||||
|
|
||||||
|
if (item.SeriesGroupId) {
|
||||||
|
// Filtern aus dem aktuell geladenen Array
|
||||||
|
let groupMembers = libraryItems.filter(i => i.SeriesGroupId === item.SeriesGroupId);
|
||||||
|
|
||||||
|
// SCHLÜSSEL: Wenn die Anzahl der gefundenen Elemente nicht mit SeriesCount übereinstimmt,
|
||||||
|
// haben wir die Gruppe noch nicht vollständig geladen.
|
||||||
|
if (groupMembers.length < (item.SeriesCount || 0)) {
|
||||||
|
console.warn("Gruppe noch nicht vollständig geladen. Anzeige ggf. unvollständig.");
|
||||||
|
// Optional: Zeige einen Ladehinweis im Modal
|
||||||
|
codesContainer.textContent = "Lade restliche Gruppenmitglieder...";
|
||||||
|
} else {
|
||||||
|
// Daten sind vollständig -> Anzeigen
|
||||||
|
const codeList = groupMembers
|
||||||
|
.sort((a, b) => (a.SeriesPosition || 0) - (b.SeriesPosition || 0))
|
||||||
|
.map(m => m.Code_4 || "---")
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
codesContainer.textContent = codeList;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editLibraryGroupCount').textContent = groupMembers.length + " / " + (item.SeriesCount || "?");
|
||||||
|
warningDiv.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
warningDiv.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate the form fields - Using correct German properties from MongoDB fields
|
document.getElementById('editLibraryModal').style.display = 'flex';
|
||||||
document.getElementById('editLibraryItemId').value = item._id || '';
|
};
|
||||||
document.getElementById('editLibraryName').value = item.Name || '';
|
|
||||||
document.getElementById('editLibraryType').value = item.ItemType || 'book';
|
|
||||||
document.getElementById('editLibraryIsbn').value = item.ISBN || '';
|
|
||||||
document.getElementById('editLibraryCode4').value = item.Code_4 || item.Code4 || '';
|
|
||||||
document.getElementById('editLibraryLocation').value = item.Ort || '';
|
|
||||||
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
|
|
||||||
|
|
||||||
document.getElementById('editLibraryModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditLibraryModal() {
|
function closeEditLibraryModal() {
|
||||||
document.getElementById('editLibraryModal').style.display = 'none';
|
document.getElementById('editLibraryModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-Listener für das Formular (Initialisierung)
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const editForm = document.getElementById('editLibraryForm');
|
||||||
|
if (editForm) {
|
||||||
|
editForm.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const itemId = document.getElementById('editLibraryItemId').value;
|
||||||
|
const currentItem = libraryItems.find(i => i._id === itemId);
|
||||||
|
|
||||||
|
if (!currentItem) return;
|
||||||
|
|
||||||
|
// 1. Alle Mitglieder der Gruppe finden, um die Code-Liste aufzubauen
|
||||||
|
const groupMembers = libraryItems.filter(i => i.SeriesGroupId === currentItem.SeriesGroupId);
|
||||||
|
const individualUpdates = groupMembers.map(member => ({
|
||||||
|
id: member._id,
|
||||||
|
// Wenn dies das bearbeitete Item ist, nimm den neuen Code, sonst den alten
|
||||||
|
code_4: (member._id === itemId) ? document.getElementById('editLibraryCode4').value : member.Code_4
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Payload für das Backend bauen
|
||||||
|
const payload = {
|
||||||
|
series_group_id: currentItem.SeriesGroupId,
|
||||||
|
name: document.getElementById('editLibraryName').value,
|
||||||
|
ort: document.getElementById('editLibraryLocation').value,
|
||||||
|
beschreibung: document.getElementById('editLibraryDescription').value,
|
||||||
|
isbn: document.getElementById('editLibraryIsbn').value,
|
||||||
|
item_type: document.getElementById('editLibraryType').value,
|
||||||
|
items: individualUpdates
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Request an die Gruppen-Update Route
|
||||||
|
try {
|
||||||
|
const response = await fetch('/update_group', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
alert('Gruppe erfolgreich synchronisiert!');
|
||||||
|
closeEditLibraryModal();
|
||||||
|
await loadLibraryItems(); // Daten neu laden
|
||||||
|
// renderTable(); // Ggf. Tabelle neu rendern
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update failed:', error);
|
||||||
|
alert('Netzwerkfehler.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="editLibraryModal" class="modal" style="display:none;">
|
<div id="editLibraryModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content" style="max-width: 760px;">
|
<div class="modal-content" style="max-width: 760px; padding: 25px; border-radius: 8px;">
|
||||||
<span class="close" onclick="closeEditLibraryModal()">×</span>
|
<span class="close" onclick="closeEditLibraryModal()" style="cursor: pointer; float: right; font-size: 24px;">×</span>
|
||||||
<h3 style="margin-top:0;">Bibliotheksmedium bearbeiten</h3>
|
<h3 style="margin-top:0;">Bibliotheksmedium bearbeiten</h3>
|
||||||
|
|
||||||
|
<!-- Bereich für Gruppen-Informationen (Hier konsolidiert!) -->
|
||||||
|
<div id="editLibraryGroupWarning" style="display:none; background-color: #fff; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #0ea5e9;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
|
||||||
|
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 12px; color: #555;">
|
||||||
|
Alle aufgeführten Codes gehören zu diesem Datensatz:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Hier werden die Codes per JS eingefügt -->
|
||||||
|
<div id="editLibraryAllCodes" style="display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px;"></div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
|
||||||
|
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="editLibraryForm">
|
<form id="editLibraryForm">
|
||||||
<input type="hidden" id="editLibraryItemId">
|
<input type="hidden" id="editLibraryItemId">
|
||||||
<div class="edit-grid">
|
<div class="edit-grid">
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label for="editLibraryName">Titel</label>
|
<label for="editLibraryName">Titel</label>
|
||||||
<input id="editLibraryName" required>
|
<input id="editLibraryName" required style="width: 100%;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="editLibraryType">Medientyp</label>
|
<label for="editLibraryType">Medientyp</label>
|
||||||
<select id="editLibraryType">
|
<select id="editLibraryType" style="width: 100%;">
|
||||||
<option value="book">Buch</option>
|
<option value="book">Buch</option>
|
||||||
<option value="schulbuch">Schulbuch</option>
|
<option value="schoolbook">Schulbuch</option>
|
||||||
<option value="cd">CD</option>
|
<option value="cd">CD</option>
|
||||||
<option value="dvd">DVD</option>
|
<option value="dvd">DVD</option>
|
||||||
<option value="media">Sonstige Medien</option>
|
<option value="other">Sonstige Medien</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="editLibraryIsbn">ISBN</label>
|
<label for="editLibraryIsbn">ISBN</label>
|
||||||
<input id="editLibraryIsbn" placeholder="optional ISBN-10/13">
|
<input id="editLibraryIsbn" placeholder="optional ISBN-10/13" style="width: 100%;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="editLibraryCode4">Code</label>
|
<label for="editLibraryCode4">Code</label>
|
||||||
<input id="editLibraryCode4" placeholder="optional Mediencode">
|
<input id="editLibraryCode4" placeholder="optional Mediencode" style="width: 100%;">
|
||||||
</div>
|
</div>
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label for="editLibraryLocation">Ort</label>
|
<label for="editLibraryLocation">Ort</label>
|
||||||
<input id="editLibraryLocation" required>
|
<input id="editLibraryLocation" required style="width: 100%;">
|
||||||
</div>
|
</div>
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label for="editLibraryDescription">Beschreibung</label>
|
<label for="editLibraryDescription">Beschreibung</label>
|
||||||
<textarea id="editLibraryDescription" rows="4" required></textarea>
|
<textarea id="editLibraryDescription" rows="4" required style="width: 100%;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-actions">
|
<div class="edit-actions" style="margin-top:20px;">
|
||||||
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern</button>
|
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern & Synchronisieren</button>
|
||||||
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
|
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="editLibraryGroupWarning" style="display:none; background-color: #fff; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #0ea5e9;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
|
||||||
|
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 12px; color: #555;">
|
||||||
|
Alle Codes in dieser Gruppe:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Hier wird die Liste als Komma-Text eingefügt -->
|
||||||
|
<div id="editLibraryAllCodes" style="font-family: monospace; font-size: 14px; font-weight: bold; color: #333; margin-top: 5px;"></div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
|
||||||
|
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -3,7 +3,12 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
|
# Directories
|
||||||
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-/var/backups/invoice-archive}"
|
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-/var/backups/invoice-archive}"
|
||||||
|
FULL_BACKUP_DIR="${FULL_BACKUP_DIR:-/var/backups/inventarsystem}"
|
||||||
|
LOG_DIR="$SCRIPT_DIR/logs"
|
||||||
|
|
||||||
KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
||||||
MODE="auto"
|
MODE="auto"
|
||||||
BASE_NAME=""
|
BASE_NAME=""
|
||||||
@@ -14,9 +19,7 @@ Usage: $0 [options]
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--invoice-archive-dir DIR Directory for invoice archive files
|
--invoice-archive-dir DIR Directory for invoice archive files
|
||||||
(default: $INVOICE_ARCHIVE_DIR)
|
--invoice-keep-days N Remove archived files older than N days
|
||||||
--invoice-keep-days N Remove archived invoice files older than N days
|
|
||||||
(default: $KEEP_DAYS)
|
|
||||||
--mode auto|docker|host Backup mode (default: auto)
|
--mode auto|docker|host Backup mode (default: auto)
|
||||||
--base-name NAME Base filename prefix for archive files
|
--base-name NAME Base filename prefix for archive files
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
@@ -26,127 +29,78 @@ EOF
|
|||||||
parse_args() {
|
parse_args() {
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--invoice-archive-dir)
|
--invoice-archive-dir) INVOICE_ARCHIVE_DIR="$2"; shift 2 ;;
|
||||||
INVOICE_ARCHIVE_DIR="$2"
|
--invoice-keep-days) KEEP_DAYS="$2"; shift 2 ;;
|
||||||
shift 2
|
--mode) MODE="$2"; shift 2 ;;
|
||||||
;;
|
--base-name) BASE_NAME="$2"; shift 2 ;;
|
||||||
--invoice-keep-days)
|
-h|--help) usage; exit 0 ;;
|
||||||
KEEP_DAYS="$2"
|
*) echo "Error: unknown option '$1'" >&2; usage; exit 2 ;;
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--mode)
|
|
||||||
MODE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--base-name)
|
|
||||||
BASE_NAME="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Error: unknown option '$1'" >&2
|
|
||||||
usage
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_directory() {
|
ensure_directory() {
|
||||||
mkdir -p "$INVOICE_ARCHIVE_DIR"
|
mkdir -p "$INVOICE_ARCHIVE_DIR"
|
||||||
|
mkdir -p "$FULL_BACKUP_DIR"
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_old_archives() {
|
cleanup_old_archives() {
|
||||||
if [[ -n "$KEEP_DAYS" ]]; then
|
if [[ -n "$KEEP_DAYS" ]]; then
|
||||||
find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) -mtime +"$KEEP_DAYS" -print -delete || true
|
find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f -mtime +"$KEEP_DAYS" -print -delete 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
# Cleanup old bundled backups (older than 30 days)
|
||||||
|
find "$FULL_BACKUP_DIR" -maxdepth 1 -type f -name "full_backup_*.tar.gz" -mtime +30 -print -delete 2>/dev/null || true
|
||||||
host_backup() {
|
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ! python3 -c 'import pymongo' >/dev/null 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 "$SCRIPT_DIR/Web/backup_invoices.py" \
|
|
||||||
--archive-dir "$INVOICE_ARCHIVE_DIR" \
|
|
||||||
--base-name "$BASE_NAME"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
docker_backup() {
|
docker_backup() {
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then return 1; fi
|
||||||
return 1
|
|
||||||
fi
|
local timestamp="$(date +'%Y-%m-%d_%H-%M-%S')"
|
||||||
if ! docker compose -f "$COMPOSE_FILE" ps -q app >/dev/null 2>&1; then
|
local staging_dir="/tmp/backup_stage_$timestamp"
|
||||||
return 1
|
|
||||||
|
echo "[$(date +'%T')] Starting unified system backup..."
|
||||||
|
mkdir -p "$staging_dir"
|
||||||
|
|
||||||
|
# 1. MongoDB Dump (into staging)
|
||||||
|
if docker compose -f "$COMPOSE_FILE" ps -q mongodb >/dev/null 2>&1; then
|
||||||
|
echo "[$(date +'%T')] Dumping Database..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" exec -T mongodb mongodump --archive --gzip > "$staging_dir/db.archive.gz"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local app_container
|
# 2. Archive Assets (into staging)
|
||||||
app_container="$(docker compose -f "$COMPOSE_FILE" ps -q app | tr -d '[:space:]')"
|
if [ -d "./Web/uploads" ]; then
|
||||||
if [[ -z "$app_container" ]]; then
|
echo "[$(date +'%T')] Archiving assets..."
|
||||||
return 1
|
tar -czf "$staging_dir/assets.tar.gz" -C . Web/uploads Web/thumbnails Web/previews 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local timestamp
|
# 3. Archive Logs (into staging)
|
||||||
timestamp="$(date +'%Y-%m-%d_%H-%M-%S')"
|
if [ -d "$LOG_DIR" ]; then
|
||||||
local output_dir
|
echo "[$(date +'%T')] Archiving logs..."
|
||||||
local remote_base
|
tar -czf "$staging_dir/logs.tar.gz" -C "$(dirname "$LOG_DIR")" "$(basename "$LOG_DIR")"
|
||||||
|
fi
|
||||||
|
|
||||||
output_dir="/tmp/invoice_archive_${timestamp}"
|
# 4. Bundle everything into one file
|
||||||
remote_base="${output_dir}/${BASE_NAME}"
|
echo "[$(date +'%T')] Bundling archive..."
|
||||||
|
tar -czf "$FULL_BACKUP_DIR/full_backup_$timestamp.tar.gz" -C "$staging_dir" .
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T app mkdir -p "$output_dir"
|
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/Web/backup_invoices.py \
|
# 5. Cleanup staging
|
||||||
--archive-dir "$output_dir" \
|
rm -rf "$staging_dir"
|
||||||
--base-name "$BASE_NAME"
|
|
||||||
|
echo "[$(date +'%T')] Backup complete: $FULL_BACKUP_DIR/full_backup_$timestamp.tar.gz"
|
||||||
mkdir -p "$INVOICE_ARCHIVE_DIR"
|
return 0
|
||||||
docker cp "$app_container":"${remote_base}.jsonl" "$INVOICE_ARCHIVE_DIR/"
|
|
||||||
docker cp "$app_container":"${remote_base}.csv" "$INVOICE_ARCHIVE_DIR/"
|
|
||||||
docker cp "$app_container":"${remote_base}.meta.json" "$INVOICE_ARCHIVE_DIR/"
|
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T app rm -rf "$output_dir"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
if [[ -z "$BASE_NAME" ]]; then
|
parse_args "$@"
|
||||||
BASE_NAME="invoices-$(date +'%Y-%m-%d_%H-%M-%S')"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ensure_directory
|
ensure_directory
|
||||||
|
|
||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
auto)
|
auto|docker)
|
||||||
if docker_backup; then
|
if docker_backup; then
|
||||||
cleanup_old_archives
|
cleanup_old_archives
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if host_backup; then
|
echo "Error: Docker backup failed." >&2
|
||||||
cleanup_old_archives
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "Error: could not perform invoice backup in auto mode. Docker or host Python with pymongo is required." >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
docker)
|
|
||||||
if docker_backup; then
|
|
||||||
cleanup_old_archives
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "Error: docker backup failed or app container is unavailable." >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
host)
|
|
||||||
if host_backup; then
|
|
||||||
cleanup_old_archives
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "Error: host backup failed. Ensure python3 and pymongo are installed." >&2
|
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -157,5 +111,4 @@ main() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_args "$@"
|
main "$@"
|
||||||
main
|
|
||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
"username": "",
|
"username": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
"from_address": "",
|
"from_address": "",
|
||||||
"default_sender_name": "Invario Inventurprogramm",
|
"default_sender_name": "Invario Inventarprogramm",
|
||||||
"timeout_seconds": 30
|
"timeout_seconds": 30
|
||||||
},
|
},
|
||||||
"images": {
|
"images": {
|
||||||
|
|||||||
+15
-14
@@ -8,8 +8,8 @@ else
|
|||||||
INSTALLER_DIR="$(pwd)"
|
INSTALLER_DIR="$(pwd)"
|
||||||
fi
|
fi
|
||||||
PROJECT_DIR="/opt/Inventarsystem"
|
PROJECT_DIR="/opt/Inventarsystem"
|
||||||
REPO_SLUG="AIIrondev/legendary-octo-garbanzo"
|
REPO_SLUG="Invario/Inventarsystem"
|
||||||
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
|
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
|
||||||
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
||||||
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
||||||
LEGACY_DB_NAME="Inventarsystem"
|
LEGACY_DB_NAME="Inventarsystem"
|
||||||
@@ -39,7 +39,7 @@ need_cmd() {
|
|||||||
|
|
||||||
refresh_start_script_from_main() {
|
refresh_start_script_from_main() {
|
||||||
local start_url
|
local start_url
|
||||||
start_url="https://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
|
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
|
||||||
|
|
||||||
if curl -fsSL "$start_url" -o "$TMP_DIR/start.sh"; then
|
if curl -fsSL "$start_url" -o "$TMP_DIR/start.sh"; then
|
||||||
sudo install -m 755 "$TMP_DIR/start.sh" "$PROJECT_DIR/start.sh"
|
sudo install -m 755 "$TMP_DIR/start.sh" "$PROJECT_DIR/start.sh"
|
||||||
@@ -62,7 +62,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
compose_file, tag = sys.argv[1], sys.argv[2]
|
compose_file, tag = sys.argv[1], sys.argv[2]
|
||||||
target_image = f"ghcr.io/aiirondev/legendary-octo-garbanzo:{tag}"
|
target_image = f"git.invario-software.eu/invario/inventarsystem:{tag}"
|
||||||
|
|
||||||
with open(compose_file, "r", encoding="utf-8") as f:
|
with open(compose_file, "r", encoding="utf-8") as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
@@ -173,9 +173,9 @@ Usage: $0 [options]
|
|||||||
Options:
|
Options:
|
||||||
--migrate-legacy-db Back up host MongoDB and import into Docker MongoDB
|
--migrate-legacy-db Back up host MongoDB and import into Docker MongoDB
|
||||||
--remove-legacy-system Remove old host MongoDB/system after successful import
|
--remove-legacy-system Remove old host MongoDB/system after successful import
|
||||||
--skip-cleanup-old Do not run cleanup-old.sh after install
|
--skip-cleanup-old Do not run cleanup-old.sh after install
|
||||||
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
||||||
--multitenant Use docker-compose-multitenant.yml (default)
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
||||||
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
||||||
--legacy-system-dir <path> Optional old system directory to remove after migration
|
--legacy-system-dir <path> Optional old system directory to remove after migration
|
||||||
@@ -432,8 +432,9 @@ main() {
|
|||||||
|
|
||||||
curl -fL "$image_url" -o "$TMP_DIR/inventarsystem-image-$tag.tar.gz"
|
curl -fL "$image_url" -o "$TMP_DIR/inventarsystem-image-$tag.tar.gz"
|
||||||
sudo docker load -i "$TMP_DIR/inventarsystem-image-$tag.tar.gz" >/dev/null
|
sudo docker load -i "$TMP_DIR/inventarsystem-image-$tag.tar.gz" >/dev/null
|
||||||
# Compatibility alias: some legacy compose bundles may still reference :latest.
|
|
||||||
sudo docker tag "ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" "ghcr.io/aiirondev/legendary-octo-garbanzo:latest" >/dev/null 2>&1 || true
|
# Tagge das geladene Gitea-Image als latest
|
||||||
|
sudo docker tag "git.invario-software.eu/invario/inventarsystem:$tag" "git.invario-software.eu/invario/inventarsystem:latest" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
if [ ! -f "$PROJECT_DIR/start.sh" ]; then
|
if [ ! -f "$PROJECT_DIR/start.sh" ]; then
|
||||||
echo "Error: release bundle is missing start.sh"
|
echo "Error: release bundle is missing start.sh"
|
||||||
@@ -470,19 +471,19 @@ EOF
|
|||||||
NUITKA_BUILD=0
|
NUITKA_BUILD=0
|
||||||
INVENTAR_HTTP_PORT=10000
|
INVENTAR_HTTP_PORT=10000
|
||||||
INVENTAR_HTTPS_PORT=10001
|
INVENTAR_HTTPS_PORT=10001
|
||||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
|
INVENTAR_APP_IMAGE=git.invario-software.eu/invario/inventarsystem:$tag
|
||||||
EOF
|
EOF
|
||||||
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
|
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
|
||||||
elif sudo grep -q '^INVENTAR_APP_IMAGE=' "$PROJECT_DIR/.docker-build.env"; then
|
elif sudo grep -q '^INVENTAR_APP_IMAGE=' "$PROJECT_DIR/.docker-build.env"; then
|
||||||
sudo sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag|" "$PROJECT_DIR/.docker-build.env"
|
sudo sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=git.invario-software.eu/invario/inventarsystem:$tag|" "$PROJECT_DIR/.docker-build.env"
|
||||||
else
|
else
|
||||||
echo "INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" | sudo tee -a "$PROJECT_DIR/.docker-build.env" >/dev/null
|
echo "INVENTAR_APP_IMAGE=git.invario-software.eu/invario/inventarsystem:$tag" | sudo tee -a "$PROJECT_DIR/.docker-build.env" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
backup_legacy_database
|
backup_legacy_database
|
||||||
|
|
||||||
echo "Starting stack..."
|
echo "Starting stack..."
|
||||||
sudo env INVENTAR_APP_IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" bash "$PROJECT_DIR/start.sh"
|
sudo env INVENTAR_APP_IMAGE="git.invario-software.eu/invario/inventarsystem:$tag" bash "$PROJECT_DIR/start.sh"
|
||||||
|
|
||||||
restore_legacy_backup_into_docker
|
restore_legacy_backup_into_docker
|
||||||
cleanup_old_services
|
cleanup_old_services
|
||||||
@@ -492,4 +493,4 @@ EOF
|
|||||||
echo "Open: https://localhost"
|
echo "Open: https://localhost"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
@@ -880,6 +880,193 @@ PY
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
backup)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
|
if [ -z "$TENANT_ID" ]; then
|
||||||
|
echo "Error: Please provide a tenant_id."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT_FILE="${3:-backup_${TENANT_ID}_$(date +%Y%m%d_%H%M%S).zip}"
|
||||||
|
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -z "$APP_CONTAINER" ]; then
|
||||||
|
echo "Error: Application container not running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating complete backup (.zip) for tenant '$TENANT_ID'..."
|
||||||
|
|
||||||
|
# We generate the zip temporarily inside the container
|
||||||
|
CONTAINER_TMP_ZIP="/tmp/backup_${TENANT_ID}_$(date +%s).zip"
|
||||||
|
|
||||||
|
docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY'
|
||||||
|
import sys, os, zipfile
|
||||||
|
from bson import json_util
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
sys.path.insert(0, '/app/Web')
|
||||||
|
from Web.modules.database import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
tenant_id = sys.argv[1]
|
||||||
|
zip_path = sys.argv[2]
|
||||||
|
|
||||||
|
# CHANGE THIS if your app stores files in a different directory
|
||||||
|
UPLOADS_BASE = f"/app/uploads/{tenant_id}"
|
||||||
|
|
||||||
|
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))
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
dump = {}
|
||||||
|
for coll_name in db.list_collection_names():
|
||||||
|
if coll_name.startswith('system.'):
|
||||||
|
continue
|
||||||
|
dump[coll_name] = list(db[coll_name].find())
|
||||||
|
|
||||||
|
db_json = json_util.dumps(dump)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# 1. Write the database dump
|
||||||
|
zf.writestr('database.json', db_json)
|
||||||
|
|
||||||
|
# 2. Add uploaded photos/invoices if the directory exists
|
||||||
|
if os.path.isdir(UPLOADS_BASE):
|
||||||
|
for root, dirs, files in os.walk(UPLOADS_BASE):
|
||||||
|
for file in files:
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
# Create a relative path for the zip internal structure
|
||||||
|
arcname = os.path.relpath(file_path, start=UPLOADS_BASE)
|
||||||
|
zf.write(file_path, arcname=f"uploads/{arcname}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating backup: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Copy the zip out of the container to the host
|
||||||
|
docker cp "$APP_CONTAINER:$CONTAINER_TMP_ZIP" "$OUTPUT_FILE"
|
||||||
|
# Cleanup inside the container
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
echo "Backup successfully created: $OUTPUT_FILE"
|
||||||
|
else
|
||||||
|
echo "Error: Backup failed."
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
restore)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
|
INPUT_FILE="${3:-}"
|
||||||
|
|
||||||
|
if [ -z "$TENANT_ID" ] || [ -z "$INPUT_FILE" ]; then
|
||||||
|
echo "Error: Usage: $0 restore <tenant_id> <backup_file.zip>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$INPUT_FILE" ]; then
|
||||||
|
echo "Error: Backup file '$INPUT_FILE' not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "WARNING: This will DROP the existing database and completely overwrite data AND files for tenant '$TENANT_ID'. Are you sure? (y/N) "
|
||||||
|
read confirm
|
||||||
|
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||||
|
echo "Restore canceled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -z "$APP_CONTAINER" ]; then
|
||||||
|
echo "Error: Application container not running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading backup archive to container..."
|
||||||
|
CONTAINER_TMP_ZIP="/tmp/restore_${TENANT_ID}_$(date +%s).zip"
|
||||||
|
docker cp "$INPUT_FILE" "$APP_CONTAINER:$CONTAINER_TMP_ZIP"
|
||||||
|
|
||||||
|
echo "Restoring database and files for tenant '$TENANT_ID'..."
|
||||||
|
|
||||||
|
docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY'
|
||||||
|
import sys, os, zipfile, shutil
|
||||||
|
from bson import json_util
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
sys.path.insert(0, '/app/Web')
|
||||||
|
from Web.modules.database import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
tenant_id = sys.argv[1]
|
||||||
|
zip_path = sys.argv[2]
|
||||||
|
|
||||||
|
# CHANGE THIS if your app stores files in a different directory
|
||||||
|
UPLOADS_BASE = f"/app/uploads/{tenant_id}"
|
||||||
|
|
||||||
|
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||||
|
db_name = f"inventar_{sanitized}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||||
|
# 1. Restore Database
|
||||||
|
db_json = zf.read('database.json').decode('utf-8')
|
||||||
|
data = json_util.loads(db_json)
|
||||||
|
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
client.drop_database(db_name)
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
restored_count = 0
|
||||||
|
for coll_name, docs in data.items():
|
||||||
|
if docs:
|
||||||
|
db[coll_name].insert_many(docs)
|
||||||
|
restored_count += len(docs)
|
||||||
|
|
||||||
|
# 2. Restore Files
|
||||||
|
# Wipe existing uploads to ensure an exact replica of the backup
|
||||||
|
if os.path.exists(UPLOADS_BASE):
|
||||||
|
shutil.rmtree(UPLOADS_BASE)
|
||||||
|
|
||||||
|
for item in zf.namelist():
|
||||||
|
# Extract only file items that were saved under 'uploads/'
|
||||||
|
if item.startswith('uploads/') and not item.endswith('/'):
|
||||||
|
rel_path = item[len('uploads/'):]
|
||||||
|
target_path = os.path.join(UPLOADS_BASE, rel_path)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||||
|
with open(target_path, 'wb') as f:
|
||||||
|
f.write(zf.read(item))
|
||||||
|
|
||||||
|
print(f"Successfully restored {restored_count} database documents and tenant files.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error restoring backup: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Clean up tmp zip inside container
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
echo "Restore completed successfully."
|
||||||
|
|
||||||
|
echo "Clearing session cache..."
|
||||||
|
docker exec "$APP_CONTAINER" python3 -c "
|
||||||
|
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
db = client[f'inventar_{"".join(c for c in sys.argv[1] if c.isalnum() or c == "_")}']
|
||||||
|
db.sessions.drop()
|
||||||
|
" "$TENANT_ID"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "Error: Restore failed."
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "Unknown command: $COMMAND"
|
echo "Unknown command: $COMMAND"
|
||||||
show_help
|
show_help
|
||||||
|
|||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/backups/inventarsystem"
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
|
# 1. Find the latest backup bundle
|
||||||
|
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/full_backup_*.tar.gz | head -n1 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -z "$LATEST_BACKUP" ]]; then
|
||||||
|
echo "Error: No backup bundle found in $BACKUP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- RESTORE PROCEDURE ---"
|
||||||
|
echo "Found latest bundle: $(basename "$LATEST_BACKUP")"
|
||||||
|
read -p "WARNING: This will OVERWRITE your current database and assets! Continue? (y/N) " confirm
|
||||||
|
[[ "$confirm" != "y" ]] && exit 1
|
||||||
|
|
||||||
|
# 2. Extract the bundle to a temporary location
|
||||||
|
RESTORE_TMP="/tmp/restore_$(date +%s)"
|
||||||
|
mkdir -p "$RESTORE_TMP"
|
||||||
|
echo "Extracting bundle..."
|
||||||
|
tar -xzf "$LATEST_BACKUP" -C "$RESTORE_TMP"
|
||||||
|
|
||||||
|
# 3. Stop application
|
||||||
|
echo "Stopping application..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" stop app
|
||||||
|
|
||||||
|
# 4. Restore Database
|
||||||
|
if [ -f "$RESTORE_TMP/db.archive.gz" ]; then
|
||||||
|
echo "Restoring MongoDB..."
|
||||||
|
zcat "$RESTORE_TMP/db.archive.gz" | docker compose -f "$COMPOSE_FILE" exec -T mongodb mongorestore --archive --gzip --drop
|
||||||
|
else
|
||||||
|
echo "Warning: Database archive not found in bundle."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Restore Assets
|
||||||
|
if [ -f "$RESTORE_TMP/assets.tar.gz" ]; then
|
||||||
|
echo "Restoring Assets (Uploads/Thumbnails/Previews)..."
|
||||||
|
tar -xzf "$RESTORE_TMP/assets.tar.gz" -C .
|
||||||
|
else
|
||||||
|
echo "Warning: Asset archive not found in bundle."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Restore Logs (Optional)
|
||||||
|
if [ -f "$RESTORE_TMP/logs.tar.gz" ]; then
|
||||||
|
echo "Restoring Logs..."
|
||||||
|
tar -xzf "$RESTORE_TMP/logs.tar.gz" -C .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Start application
|
||||||
|
echo "Restarting application..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" start app
|
||||||
|
|
||||||
|
# 8. Cleanup
|
||||||
|
rm -rf "$RESTORE_TMP"
|
||||||
|
echo "Restore complete!"
|
||||||
@@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
|
||||||
DIST_DIR="$SCRIPT_DIR/dist"
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||||
|
|
||||||
@@ -676,4 +676,4 @@ fi
|
|||||||
verify_stack_health
|
verify_stack_health
|
||||||
|
|
||||||
echo "Stack started."
|
echo "Stack started."
|
||||||
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
|
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
|
||||||
@@ -2,18 +2,18 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Release-only updater for Docker deployment.
|
# Release-only updater for Docker deployment.
|
||||||
# Updates are pulled exclusively from GitHub Releases assets.
|
# Updates are pulled exclusively from Gitea Releases assets.
|
||||||
|
|
||||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
LOG_DIR="$PROJECT_DIR/logs"
|
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="AIIrondev/legendary-octo-garbanzo"
|
REPO_SLUG="Invario/Inventarsystem"
|
||||||
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
|
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
|
||||||
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
||||||
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
||||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
|
||||||
DIST_DIR="$PROJECT_DIR/dist"
|
DIST_DIR="$PROJECT_DIR/dist"
|
||||||
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}"
|
||||||
@@ -153,7 +153,7 @@ Usage: $0 [options]
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--multitenant Use docker-compose-multitenant.yml (default)
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
development Install development build from GHCR or local dist
|
development Install development build from Gitea Registry or local dist
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -204,14 +204,14 @@ create_backup() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -x "$PROJECT_DIR/run-backup.sh" ]; then
|
if [ -x "$PROJECT_DIR/backup.sh" ]; then
|
||||||
if "$PROJECT_DIR/run-backup.sh" >> "$LOG_FILE" 2>&1; then
|
if "$PROJECT_DIR/backup.sh" --mode auto >> "$LOG_FILE" 2>&1; then
|
||||||
log_message "Backup completed"
|
log_message "Backup completed"
|
||||||
else
|
else
|
||||||
log_message "WARNING: Backup failed; continuing with release update"
|
log_message "WARNING: Backup failed; continuing with release update"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log_message "WARNING: run-backup.sh not found; skipping backup"
|
log_message "WARNING: backup.sh not found; skipping backup"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,10 +275,10 @@ load_release_image() {
|
|||||||
|
|
||||||
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://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
|
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
|
||||||
stop_url="https://raw.githubusercontent.com/$REPO_SLUG/main/stop.sh"
|
stop_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/stop.sh"
|
||||||
restart_url="https://raw.githubusercontent.com/$REPO_SLUG/main/restart.sh"
|
restart_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/restart.sh"
|
||||||
update_url="https://raw.githubusercontent.com/$REPO_SLUG/main/update.sh"
|
update_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/update.sh"
|
||||||
|
|
||||||
curl -fsSL "$start_url" -o "$PROJECT_DIR/start.sh" || log_message "WARNING: Could not refresh start.sh from main"
|
curl -fsSL "$start_url" -o "$PROJECT_DIR/start.sh" || log_message "WARNING: Could not refresh start.sh from main"
|
||||||
curl -fsSL "$stop_url" -o "$PROJECT_DIR/stop.sh" || log_message "WARNING: Could not refresh stop.sh from main"
|
curl -fsSL "$stop_url" -o "$PROJECT_DIR/stop.sh" || log_message "WARNING: Could not refresh stop.sh from main"
|
||||||
@@ -357,7 +357,7 @@ download_and_extract_bundle() {
|
|||||||
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
|
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Neu: Multi-Tenant Ressourcen kopieren, falls vorhanden
|
# Multi-Tenant Ressourcen kopieren, falls vorhanden
|
||||||
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
|
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
|
||||||
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
|
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
|
||||||
fi
|
fi
|
||||||
@@ -374,7 +374,7 @@ download_and_extract_bundle() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
# Ensure executable permissions on all copied scripts
|
# Ensure executable permissions on all copied scripts
|
||||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true
|
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
|
||||||
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
|
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
|
||||||
|
|
||||||
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
|
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
|
||||||
@@ -412,7 +412,7 @@ EOF
|
|||||||
|
|
||||||
if ! load_local_dist_image "$tag"; then
|
if ! load_local_dist_image "$tag"; then
|
||||||
if ! load_release_image "$meta_file" "$tag"; then
|
if ! load_release_image "$meta_file" "$tag"; then
|
||||||
log_message "Falling back to tagged GHCR image $app_image"
|
log_message "Falling back to tagged Gitea Registry image $app_image"
|
||||||
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
||||||
log_message "Falling back to local Docker build for $app_image"
|
log_message "Falling back to local Docker build for $app_image"
|
||||||
docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1
|
docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1
|
||||||
@@ -511,7 +511,7 @@ EOF
|
|||||||
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
|
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Try local dist first, then pull from GHCR
|
# Try local dist first, then pull from Gitea Registry
|
||||||
if ! load_local_dist_image "$tag"; then
|
if ! load_local_dist_image "$tag"; then
|
||||||
log_message "Attempting to pull development image $app_image"
|
log_message "Attempting to pull development image $app_image"
|
||||||
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
||||||
@@ -540,7 +540,7 @@ EOF
|
|||||||
|
|
||||||
trap 'rm -rf "${tmp_dir:-}"' EXIT
|
trap 'rm -rf "${tmp_dir:-}"' EXIT
|
||||||
|
|
||||||
log_message "Checking latest GitHub release for $REPO_SLUG..."
|
log_message "Checking latest Gitea release for $REPO_SLUG..."
|
||||||
if ! fetch_release_metadata "$meta_file"; then
|
if ! fetch_release_metadata "$meta_file"; 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
|
||||||
@@ -621,6 +621,9 @@ EOF
|
|||||||
cleanup_old_dist_artifacts
|
cleanup_old_dist_artifacts
|
||||||
cleanup_docker_dangling_images
|
cleanup_docker_dangling_images
|
||||||
log_message "Update completed successfully to release $latest_tag"
|
log_message "Update completed successfully to release $latest_tag"
|
||||||
|
|
||||||
|
sudo ./opt/Inventarsystem/restart.sh
|
||||||
|
echo "Restart of the Server Completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
Reference in New Issue
Block a user