Compare commits

...

53 Commits

Author SHA1 Message Date
Aiirondev_dev 20528b60c5 changes 2026-07-16 14:53:27 +02:00
Aiirondev_dev c610c06a0e changes for the backup process 2026-07-16 14:40:06 +02:00
Aiirondev_dev 2cef1672d2 changes to the backup 2026-07-16 14:12:54 +02:00
Aiirondev_dev eb8542c410 changes to the backup.sh 2026-07-16 13:49:19 +02:00
Aiirondev_dev 1ebd83ec2c changes to the backup.sh 2026-07-16 13:41:39 +02:00
Aiirondev_dev e80bf946c0 changes to the ussage of backup.sh 2026-07-16 13:39:21 +02:00
Aiirondev_dev bfbbff597e final changes to account for the switch from github to gitea 2026-07-16 12:08:09 +02:00
Aiirondev_dev d80f3f56b7 changes to the release action 2026-07-16 11:16:54 +02:00
Aiirondev_dev 726fca0866 fix of wrong Registry keys
Release Inventarsystem / release-docker (push) Failing after 2m40s
2026-07-16 10:26:28 +02:00
Aiirondev_dev 614f73250a changes to the release 2026-07-16 10:10:56 +02:00
Aiirondev_dev 0d2488eb0b release changes 2026-07-16 08:23:10 +02:00
Aiirondev_dev 7e6a1d6b03 fiy of a typo 2026-07-16 08:15:15 +02:00
Aiirondev_dev ebf144f1a9 changes to the remote execution of the release 2026-07-16 08:09:15 +02:00
Aiirondev_dev 78fda8d569 Test deployment 2026-07-14 01:02:56 +02:00
Aiirondev_dev 248594ce0d adjustment for the runner 2026-07-14 00:59:38 +02:00
Aiirondev_dev 783c68a243 Try difrent adjustments 2026-07-14 00:53:19 +02:00
Aiirondev_dev 031d41643e adjustment for a problem with the ubuntu-latest 2026-07-14 00:51:31 +02:00
Aiirondev_dev cae3199e53 Adjustments to the release-docker.yml 2026-07-14 00:48:03 +02:00
Aiirondev_dev d0f2afae57 slight changes to the dispaying of the Detailed Library Modals 2026-07-07 22:25:55 +02:00
Aiirondev_dev 1a99448a2e slight css fix for the accesability of the script 2026-07-07 21:43:44 +02:00
Aiirondev_dev aec25a13b9 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-07-05 13:40:59 +02:00
Aiirondev_dev b31abe2177 Adjustments to the intput field for the ÖLibrary funktion 2026-07-05 13:40:48 +02:00
Aiirondev_dev 7fd27e8c02 additional changes to account for legacy Item Types 2026-07-05 12:02:57 +02:00
Aiirondev_dev 5f5784e7c3 addition of a debuging step 2026-07-05 11:52:57 +02:00
Aiirondev_dev 613c1e11e4 slight changes of important kexy filters for the retrival of Library Items 2026-07-05 11:46:30 +02:00
Aiirondev_dev 9e3b54c1b1 slight changes to the updating process to restart after such an event 2026-07-05 11:40:06 +02:00
Aiirondev_dev a8bce26eab Adjust for a normal scanner to be able t scann as well 2026-06-30 12:04:35 +02:00
Aiirondev_dev 36c391e99c fix in the export of excel lists 2026-06-30 11:32:17 +02:00
Aiirondev_dev 40c3970719 better excel export 2026-06-30 11:22:18 +02:00
Aiirondev_dev e7f4b1ce9d Slkight fix for the Items types 2026-06-30 10:43:09 +02:00
Aiirondev_dev d84466a3cc changes to the Library Item Type to reflekt the correct types 2026-06-30 10:04:59 +02:00
Aiirondev_dev 0efc01a835 slight adjustments 2026-06-28 23:47:58 +02:00
Aiirondev_dev 73cf63a56d changes 2026-06-28 23:34:42 +02:00
Aiirondev_dev fdcd26326b slight changes to correctly reflect all relevant things 2026-06-28 23:28:42 +02:00
Aiirondev_dev 1fd9e230ac changes of the upddating of library Items 2026-06-28 23:13:36 +02:00
Aiirondev_dev cc0c4e28b7 Development 2026-06-28 18:28:29 +02:00
Aiirondev_dev ea73b98549 Adjustments to the edeting funktion fpo 2026-06-28 17:03:10 +02:00
Aiirondev_dev d6dda0f25a Slight adjustments 2026-06-28 16:55:57 +02:00
Aiirondev_dev 46176c1741 slight fixes 2026-06-28 16:45:53 +02:00
Aiirondev_dev c57b4a9a44 edit Modal changes to accomidate the library Modul 2026-06-28 16:30:41 +02:00
Aiirondev_dev 6f424cc8aa Backup and restore funktionality 2026-06-28 15:55:49 +02:00
Aiirondev_dev 103b82af15 smal fix 2026-06-28 00:11:16 +02:00
Aiirondev_dev 69c8aa8700 Development fix 2026-06-27 23:59:56 +02:00
Aiirondev_dev 1fa3dace17 changes for a smal issue 2026-06-27 23:51:47 +02:00
Aiirondev_dev 6dced8b1a5 additional fix for the cathergorie 2026-06-27 23:33:01 +02:00
Aiirondev_dev 493ee2ea7f fiy of a funktion issue 2026-06-27 23:28:00 +02:00
Aiirondev_dev 52a2ec0cad Adding of the detailed module 2026-06-27 23:10:14 +02:00
Aiirondev_dev b5f2c3c5c5 Adding of the category 2026-06-27 23:09:24 +02:00
Aiirondev_dev 403c281c93 Debug? 2026-06-27 22:52:40 +02:00
Aiirondev_dev 6d121f6110 Slight adjustment 2026-06-27 22:37:04 +02:00
Aiirondev_dev 9d940c6151 Denbugging and fix of a Code_4 generation issue 2026-06-27 22:20:10 +02:00
Aiirondev_dev 980b825e07 Test 2026-06-27 22:11:28 +02:00
Aiirondev_dev 94326728eb development debuggig 2026-06-27 22:01:51 +02:00
11 changed files with 736 additions and 209 deletions
+62 -33
View File
@@ -1,4 +1,4 @@
name: https://github.com/AIIrondev/legendary-octo-garbanzo
name: Release Inventarsystem
on:
push:
@@ -25,6 +25,7 @@ env:
jobs:
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
steps:
@@ -34,18 +35,20 @@ jobs:
- name: Set metadata
id: meta
env:
# Gitea stellt das GITHUB_TOKEN für Kompatibilität mit GitHub Actions automatisch zur Verfügung
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
REPO: ${{ gitea.repository }}
EVENT_NAME: ${{ gitea.event_name }}
REF_NAME: ${{ gitea.ref_name }}
BUMP_TYPE: ${{ gitea.event.inputs.bump || 'patch' }}
DOCKER_API_VERSION: '1.44'
run: |
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
TAG="$REF_NAME"
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"
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)
if [ -n "$tag_name" ]; then
latest_tag="$tag_name"
@@ -108,19 +111,58 @@ jobs:
TAG="${base}.${i}"
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 "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
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: git.invario-software.eu
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push release image
uses: docker/build-push-action@v6
@@ -128,10 +170,9 @@ jobs:
context: .
file: ./Dockerfile
push: true
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
tags: |
${{ steps.meta.outputs.image }}
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
- name: Build local image tar for offline deploy
run: |
@@ -139,20 +180,10 @@ jobs:
IMG="${{ steps.meta.outputs.image }}"
TAG="${{ steps.meta.outputs.tag }}"
if docker image inspect "$IMG" >/dev/null 2>&1; then
echo "Using local image $IMG"
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
exit 0
fi
echo "Pulling freshly pushed image from registry: $IMG"
docker pull "$IMG"
echo "Local image $IMG not found, trying to pull"
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" .
echo "Saving image to offline tar archive..."
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
- name: Create release-only docker bundle
@@ -161,7 +192,7 @@ jobs:
cat > release-bundle/docker-compose.yml <<EOF
services:
app:
image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
image: \${INVENTAR_APP_IMAGE:-${{ steps.meta.outputs.image }}}
container_name: inventarsystem-app
restart: unless-stopped
ports:
@@ -242,12 +273,10 @@ jobs:
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release
uses: softprops/action-gh-release@v2
- name: Create or update Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.meta.outputs.tag }}
files: |
inventarsystem-docker-bundle.tar.gz
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
fail_on_unmatched_files: false
generate_release_notes: true
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
+3 -3
View File
@@ -120,19 +120,19 @@ Das Inventarsystem stellt folgende Wartungsskripte bereit:
**Option 1**
```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**
```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:
```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
```
+141 -40
View File
@@ -42,6 +42,7 @@ import Web.modules.database.termine as termin
import Web.modules.log.audit_log as al
import push_notifications as pn
import Web.modules.inventarsystem.pdf_export as pdf_export
import Web.modules.inventarsystem.excel_export as excel_export
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from bson.objectid import ObjectId
@@ -288,7 +289,7 @@ SCHEDULER_INTERVAL = cfg.SCHEDULER_INTERVAL_MIN
SSL_CERT = cfg.SSL_CERT
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'
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,
isbn=row['isbn'],
item_type=row['item_type'],
is_library=False
)
if not item_id:
import_errors.append((row['row_number'], 'Fehler beim Anlegen des Artikels'))
@@ -2979,33 +2981,50 @@ def library_export_excel(scope='all'):
username = session['username']
is_admin_user = us.check_admin(username)
import Web.modules.inventarsystem.excel_export as excel_export
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items_collection = db['items']
query = {'ItemType': {'$in': LIBRARY_ITEM_TYPES}, 'Deleted': {'$ne': True}}
# Base query
query = {
'ItemType': {'$in': LIBRARY_ITEM_TYPES},
'Deleted': {'$ne': True}
}
# Scope modifications
if scope == 'borrowed_by_me':
query['User'] = username
query['Verfuegbar'] = False
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
elif scope == 'all_borrowed':
if not is_admin_user:
# Consider adding a flash message here before redirecting
return redirect('/library')
query['Verfuegbar'] = False
filename = "Bibliothek_Alle_Ausleihen.xlsx"
elif scope == 'schulbuecher':
# Overwrites the base $in query, which is fine
query['ItemType'] = 'schulbuch'
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:
# 'all'
# 'all' scope
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))
client.close()
# Generate and send file
excel_file = excel_export.generate_library_excel(items)
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -3193,7 +3212,7 @@ def api_library_items():
ausleihungen_db = db['ausleihungen']
query = {
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
'ItemType': {'$in': ['book', 'cd', 'dvd', 'other', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']},
'IsGroupedSubItem': {'$ne': True},
'Deleted': {'$ne': True}
}
@@ -3567,15 +3586,20 @@ def api_item_detail(item_id):
for rec in borrow_records:
if rec.get('Status') == 'active' and rec.get('User'):
try:
borrower_value = decrypt_text(rec.get('User'))
user_raw = rec.get('User')
if user_raw is not None:
borrower_value = decrypt_text(str(user_raw))
except Exception:
borrower_value = rec.get('User')
borrower_value = str(rec.get('User', ''))
break
if not borrower_value and item.get('User'):
try:
borrower_value = decrypt_text(item.get('User'))
item_user = item.get('User')
if item_user is not None:
borrower_value = decrypt_text(str(item_user))
except Exception:
borrower_value = item.get('User')
borrower_value = str(item.get('User', ''))
# Helper to format datetimes
def fmt_dt(dt):
@@ -3584,32 +3608,41 @@ def api_item_detail(item_id):
except Exception:
return str(dt) if dt else ''
# Build HTML for borrow records (if any)
borrows_html = ''
if borrow_records:
rows = []
for rec in borrow_records:
user_raw = rec.get('User') or ''
user_raw = rec.get('User')
try:
user = decrypt_text(user_raw) if user_raw else ''
user = decrypt_text(user_raw) if user_raw is not None else ''
except Exception:
user = user_raw
status = rec.get('Status', '')
start = fmt_dt(rec.get('Start'))
end = fmt_dt(rec.get('End'))
notes = html.escape(str(rec.get('Notes') or ''))
rows.append(f"<li><strong>{html.escape(user or '-')}</strong> — {html.escape(status)}{html.escape(start)}{html.escape(end)}{('' + notes) if notes else ''}</li>")
rows.append(
f"<li><strong>{html.escape(str(user or '-'))}</strong> — "
f"{html.escape(str(status))}"
f"{html.escape(str(start))}{html.escape(str(end))}"
f"{('' + notes) if notes else ''}</li>"
)
borrows_html = f"<h3>Ausleihhistorie</h3><ul>{''.join(rows)}</ul>"
# Basic detail HTML
detail_html = f"""
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
<p><strong>ISBN:</strong> {html.escape(item.get('ISBN', item.get('Code4', '-')))}</p>
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
<p><strong>Status:</strong> {html.escape(status_label)}</p>
<h2>{html.escape(str(item.get('Name', 'Untitled')))}</h2>
<p><strong>ISBN:</strong> {html.escape(str(item.get('ISBN', item.get('Code4', '-'))))}</p>
<p><strong>Anzahl:</strong> {html.escape(str(item.get('SeriesCount', '-')))}</p>
<p><strong>Ort:</strong> {html.escape(str(item.get('Ort', '-')))}</p>
<p><strong>Typ:</strong> {html.escape(str(item.get('ItemType', '-')))}</p>
<p><strong>Kategorie:</strong> {html.escape(str(item.get('library_category', '-')))}</p>
<p><strong>Beschreibung:</strong> {html.escape(str(item.get('Beschreibung', '-')))}</p>
<p><strong>Status:</strong> {html.escape(str(status_label))}</p>
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
{borrows_html}
"""
client.close()
return detail_html, 200
except Exception as e:
@@ -5101,6 +5134,8 @@ def upload_item():
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
item_is_library = False
# Check if user may insert items
username = session['username']
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
@@ -5138,12 +5173,15 @@ def upload_item():
anschaffungs_jahr = sanitize_form_value(request.form.getlist('anschaffungsjahr'))
anschaffungs_kosten = sanitize_form_value(request.form.getlist('anschaffungskosten'))
code_4 = sanitize_form_value(request.form.getlist('code_4'))
app.logger.info(f"Upload attempt by {encrypt_text(username)}: name={name!r}, ort={ort!r}, beschreibung length={len(beschreibung)}, images count={len(images)}, filters={filter_upload}, filters2={filter_upload2}, filters3={filter_upload3}, anschaffungs_jahr={anschaffungs_jahr}, anschaffungs_kosten={anschaffungs_kosten}, code_4={code_4}")
isbn_raw = sanitize_form_value(request.form.get('isbn', ''))
upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item'))
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
library_category = sanitize_form_value(request.form.get('library_category', ''))
if library_category != '':
item_is_library = True
try:
item_count = int(item_count_raw) if item_count_raw else 1
@@ -5156,12 +5194,12 @@ def upload_item():
if individual_codes_raw:
individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if c.strip()]
# Check if this is a duplication
is_duplicating = request.form.get('is_duplicating') == 'true'
# Get duplicate_images if duplicating
duplicate_images = request.form.getlist('duplicate_images') if is_duplicating else []
print(f"DEBUG: Duplicate images from form: {duplicate_images}, count: {len(duplicate_images)}")
# Make sure duplicate_images is always a list, even if there's only one
if is_duplicating and duplicate_images and not isinstance(duplicate_images, list):
@@ -5213,7 +5251,7 @@ def upload_item():
return redirect(url_for(success_redirect_endpoint))
item_isbn = ''
item_type = 'general'
item_type = 'other'
if cfg.MODULES.is_enabled('library') and isbn_raw:
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
if normalized_isbn:
@@ -5221,8 +5259,7 @@ def upload_item():
item_type = 'book'
else:
item_isbn = isbn_raw
if upload_mode == 'library':
item_type = 'book'
if item_type_input != "":
item_type = item_type_input
@@ -5239,7 +5276,6 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
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
# For library mode, skip this check as images come only from ISBN fetch
@@ -5302,7 +5338,7 @@ def upload_item():
# Validate every code in our master list against the database
for code in all_item_codes:
if not it.is_code_unique(code):
app.logger.info(f"DEBUG: Code '{code}' is not unique.")
# Special case: If they only uploaded ONE item, redirect them to that existing item
if item_count == 1:
existing_item = it._get_items_collection().find_one({"code_4": code})
@@ -5335,13 +5371,13 @@ def upload_item():
if not base_code:
return None
candidate = base_code if position == 1 else f"{base_code}-{position}"
candidate = base_code if position == 1 else f"{base_code}"
if it.is_code_unique(candidate):
return candidate
suffix = 1
while suffix <= 1000:
alternative = f"{candidate}-{suffix}"
alternative = f"{candidate}"
if it.is_code_unique(alternative):
return alternative
suffix += 1
@@ -5992,9 +6028,6 @@ def upload_item():
else:
app.logger.warning(f"Book cover image not found: {book_cover_image}")
# Log image processing stats
app.logger.info(f"Upload stats: processed={processed_count}, errors={error_count}, skipped={skipped_count}, duplicates={len(duplicate_images) if duplicate_images else 0}")
# If location is not in the predefined list, add it
predefined_locations = it.get_predefined_locations()
if ort and ort not in predefined_locations:
@@ -6035,7 +6068,9 @@ def upload_item():
is_grouped_sub_item=(position > 1),
parent_item_id=parent_item_id,
isbn=item_isbn,
item_type=item_type
item_type=item_type,
library_category=library_category,
is_library=item_is_library
)
if not item_id:
break
@@ -6589,23 +6624,89 @@ def edit_item(id):
if ort and ort not in predefined_locations:
it.add_predefined_location(ort)
# Update the item
result = it.update_item(
id, name, ort, beschreibung,
images, verfuegbar, filter1, filter2, filter3,
anschaffungs_jahr, anschaffungs_kosten, code_4, reservierbar,
id=id,
name=name,
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,
item_type=item_type
)
if result:
flash('Element erfolgreich aktualisiert', 'success')
flash('Element erfolgreich aktualisiert (und ggf. Gruppe synchronisiert)', 'success')
else:
flash('Fehler beim Aktualisieren des Elements', 'error')
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'])
def report_damage(id):
"""Register a damage report entry for an item (admin only)."""
+37 -40
View File
@@ -24,7 +24,7 @@ import Web.modules.database.settings as cfg
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):
@@ -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,
series_group_id=None, series_count=1, series_position=1,
is_grouped_sub_item=False, parent_item_id=None,
isbn=None, item_type='general'):
isbn=None, item_type='general', library_category=None, is_library=False):
"""
Add a new item to the inventory.
@@ -70,6 +70,9 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
series_position (int, optional): Position inside the batch (1-based)
is_grouped_sub_item (bool, optional): Whether this item is hidden as sub-item
parent_item_id (str, optional): Parent item id if this is a sub-item
isbn (str, optional): ISBN for books or media items
item_type (str, optional): Type of the item (e.g., 'general', 'book', 'cd')
library_category (str, optional): Library category for the item
Returns:
ObjectId: ID of the new item or None if failed
@@ -97,7 +100,9 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
'Anschaffungskosten': ansch_kost,
'Code_4': code_4,
'ISBN': isbn,
'library_category': library_category,
'ItemType': item_type,
'is_library': is_library,
'SeriesGroupId': series_group_id,
'SeriesCount': series_count,
'SeriesPosition': series_position,
@@ -194,69 +199,61 @@ def get_group_item_ids(id):
return []
def update_item(id, name, ort, beschreibung, images=None, verfuegbar=True,
filter=None, filter2=None, filter3=None, ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True,
isbn=None, item_type='general'):
"""
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
Returns:
bool: True if successful, False otherwise
"""
def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3,
ansch_jahr, ansch_kost, code_4, reservierbar, isbn=None, item_type='general'):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
# Set default values for optional parameters
if images is None:
images = []
# 1. Altes Item laden, um SeriesGroupId zu bestimmen
old_item = items.find_one({'_id': ObjectId(id)})
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,
'Ort': ort,
'Beschreibung': beschreibung,
'Images': images,
'Verfuegbar': verfuegbar,
'Reservierbar': reservierbar,
'Filter': filter,
'Filter': filter1,
'Filter2': filter2,
'Filter3': filter3,
'Anschaffungsjahr': ansch_jahr,
'Anschaffungskosten': ansch_kost,
'Code_4': code_4,
'Reservierbar': reservierbar,
'ISBN': isbn,
'ItemType': item_type,
'Verfuegbar': verfuegbar, # Wir behalten den Status bei
'LastUpdated': datetime.datetime.now()
}
result = items.update_one(
{'_id': ObjectId(id)},
{'$set': update_data}
)
# 3. Spezifische Daten: Was NICHT synchronisiert wird
specific_update = shared_update.copy()
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()
return result.modified_count > 0
return True
except Exception as e:
print(f"Error updating item: {e}")
return False
def update_item_status(id, verfuegbar, user=None):
"""
Update the availability status of an inventory item.
+15 -12
View File
@@ -37,20 +37,23 @@ def generate_library_excel(items):
item.get("ReturnDate", ""),
item.get("Anschaffungskosten", "")
]
ws.append(row)
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
ws.column_dimensions[column].width = min(max_length + 2, 50)
# FIX: Clean the row data INSIDE the loop for every single item
clean_row = []
for value in row:
if isinstance(value, list):
# Converts ['A', 'B'] to "A, B" and an empty list [] to an empty string ""
clean_row.append(", ".join(map(str, value)) if value else "")
elif isinstance(value, dict):
# Just in case there is an embedded MongoDB sub-document
clean_row.append(str(value))
else:
clean_row.append(value)
# Append the safe, flattened row to the worksheet
ws.append(clean_row)
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
return excel_buffer
return excel_buffer
+239 -39
View File
@@ -4,6 +4,57 @@
{% block content %}
<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-container {
max-width: 1400px;
@@ -475,7 +526,7 @@
<select id="filterType">
<option value="">-- Alle Typen --</option>
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="schoolbook">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</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>
Alle ausgeliehen Excel
</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>
@@ -536,15 +591,14 @@
</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 class="modal-content">
<span class="close" onclick="closeDetailModal()">&times;</span>
<div id="detailContent"></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>
// =========================================================================
@@ -1033,7 +1087,7 @@
}
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;
}
@@ -1043,20 +1097,47 @@
return div.innerHTML;
}
// Opens the modal and fetches the data
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}`)
.then(r => r.text())
.then(html => {
document.getElementById('detailContent').innerHTML = html;
document.getElementById('detailModal').style.display = 'flex';
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
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() {
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
// =========================================================================
@@ -1192,75 +1273,194 @@
}
});
// Modal Control Functions (accessible globally from table buttons)
function openEditLibraryItem(itemId) {
window.openEditLibraryItem = function(itemId) {
const item = libraryItems.find(i => i._id === itemId);
if (!item) {
alert('Fehler: Das Medium konnte nicht gefunden werden.');
return;
if (!item) return;
// 1. Felder befüllen
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('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';
}
document.getElementById('editLibraryModal').style.display = 'flex';
};
function closeEditLibraryModal() {
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>
<div id="editLibraryModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 760px;">
<span class="close" onclick="closeEditLibraryModal()">&times;</span>
<div class="modal-content" style="max-width: 760px; padding: 25px; border-radius: 8px;">
<span class="close" onclick="closeEditLibraryModal()" style="cursor: pointer; float: right; font-size: 24px;">&times;</span>
<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">
<input type="hidden" id="editLibraryItemId">
<div class="edit-grid">
<div class="full">
<label for="editLibraryName">Titel</label>
<input id="editLibraryName" required>
<input id="editLibraryName" required style="width: 100%;">
</div>
<div>
<label for="editLibraryType">Medientyp</label>
<select id="editLibraryType">
<select id="editLibraryType" style="width: 100%;">
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="schoolbook">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</option>
<option value="media">Sonstige Medien</option>
<option value="other">Sonstige Medien</option>
</select>
</div>
<div>
<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>
<label for="editLibraryCode4">Code</label>
<input id="editLibraryCode4" placeholder="optional Mediencode">
<input id="editLibraryCode4" placeholder="optional Mediencode" style="width: 100%;">
</div>
<div class="full">
<label for="editLibraryLocation">Ort</label>
<input id="editLibraryLocation" required>
<input id="editLibraryLocation" required style="width: 100%;">
</div>
<div class="full">
<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 class="edit-actions">
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern</button>
<div class="edit-actions" style="margin-top:20px;">
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern & Synchronisieren</button>
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
</div>
</form>
</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 %}
+14 -8
View File
@@ -73,7 +73,7 @@ host_backup() {
return 1
fi
python3 "$SCRIPT_DIR/Web/backup_invoices.py" \
python3 "$SCRIPT_DIR/Web/modules/inventarsystem/backup_invoices.py" \
--archive-dir "$INVOICE_ARCHIVE_DIR" \
--base-name "$BASE_NAME"
}
@@ -101,16 +101,22 @@ docker_backup() {
remote_base="${output_dir}/${BASE_NAME}"
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 \
docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/Web/modules/inventarsystem/backup_invoices.py \
--archive-dir "$output_dir" \
--base-name "$BASE_NAME"
mkdir -p "$INVOICE_ARCHIVE_DIR"
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/"
if docker compose -f "$COMPOSE_FILE" exec -T app test -f "${remote_base}.jsonl"; then
mkdir -p "$INVOICE_ARCHIVE_DIR"
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/"
else
echo "Info: Keine Backup-Dateien zum Kopieren gefunden (Datenbank ist leer). Ueberspringe Dateitransfer."
fi
docker compose -f "$COMPOSE_FILE" exec -T app rm -rf "$output_dir"
return 0
}
main() {
@@ -158,4 +164,4 @@ main() {
}
parse_args "$@"
main
main
+15 -14
View File
@@ -8,8 +8,8 @@ else
INSTALLER_DIR="$(pwd)"
fi
PROJECT_DIR="/opt/Inventarsystem"
REPO_SLUG="AIIrondev/legendary-octo-garbanzo"
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
REPO_SLUG="Invario/Inventarsystem"
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
LEGACY_DB_NAME="Inventarsystem"
@@ -39,7 +39,7 @@ need_cmd() {
refresh_start_script_from_main() {
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
sudo install -m 755 "$TMP_DIR/start.sh" "$PROJECT_DIR/start.sh"
@@ -62,7 +62,7 @@ import re
import sys
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:
lines = f.readlines()
@@ -173,9 +173,9 @@ Usage: $0 [options]
Options:
--migrate-legacy-db Back up host MongoDB and import into Docker MongoDB
--remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--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"
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
echo "Error: release bundle is missing start.sh"
@@ -470,19 +471,19 @@ EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000
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
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
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
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
backup_legacy_database
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
cleanup_old_services
@@ -492,4 +493,4 @@ EOF
echo "Open: https://localhost"
}
main "$@"
main "$@"
+187
View File
@@ -880,6 +880,193 @@ PY
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"
show_help
+2 -2
View File
@@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
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"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
@@ -676,4 +676,4 @@ fi
verify_stack_health
echo "Stack started."
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+21 -18
View File
@@ -2,18 +2,18 @@
set -euo pipefail
# 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)"
LOG_DIR="$PROJECT_DIR/logs"
LOG_FILE="$LOG_DIR/update.log"
STATE_FILE="$PROJECT_DIR/.release-version"
REPO_SLUG="AIIrondev/legendary-octo-garbanzo"
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
REPO_SLUG="Invario/Inventarsystem"
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
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"
COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
@@ -153,7 +153,7 @@ Usage: $0 [options]
Options:
--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
EOF
}
@@ -204,14 +204,14 @@ create_backup() {
fi
fi
if [ -x "$PROJECT_DIR/run-backup.sh" ]; then
if "$PROJECT_DIR/run-backup.sh" >> "$LOG_FILE" 2>&1; then
if [ -x "$PROJECT_DIR/backup.sh" ]; then
if "$PROJECT_DIR/backup.sh" --mode auto >> "$LOG_FILE" 2>&1; then
log_message "Backup completed"
else
log_message "WARNING: Backup failed; continuing with release update"
fi
else
log_message "WARNING: run-backup.sh not found; skipping backup"
log_message "WARNING: backup.sh not found; skipping backup"
fi
}
@@ -275,10 +275,10 @@ load_release_image() {
refresh_runtime_scripts_from_main() {
local start_url stop_url restart_url update_url
start_url="https://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
stop_url="https://raw.githubusercontent.com/$REPO_SLUG/main/stop.sh"
restart_url="https://raw.githubusercontent.com/$REPO_SLUG/main/restart.sh"
update_url="https://raw.githubusercontent.com/$REPO_SLUG/main/update.sh"
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
stop_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/stop.sh"
restart_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/restart.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 "$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"
fi
# Neu: Multi-Tenant Ressourcen kopieren, falls vorhanden
# Multi-Tenant Ressourcen kopieren, falls vorhanden
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
fi
@@ -374,7 +374,7 @@ download_and_extract_bundle() {
done
# 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
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_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
log_message "Falling back to local Docker build for $app_image"
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"
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
log_message "Attempting to pull development image $app_image"
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
@@ -540,7 +540,7 @@ EOF
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
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
@@ -621,6 +621,9 @@ EOF
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag"
sudo ./opt/Inventarsystem/restart.sh
echo "Restart of the Server Completed"
}
main "$@"
main "$@"