Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ccee38cb | |||
| 9255c87f57 | |||
| a6b246a92b | |||
| 25f52eeeb5 | |||
| c07d4e0bdd | |||
| 0cacfb0871 | |||
| d3bfaa4580 | |||
| 36531662d3 | |||
| 35b87a9a98 | |||
| 80262aca9b | |||
| 71a8823b35 |
@@ -4,17 +4,20 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump:
|
release_type:
|
||||||
description: "Version bump type (major stays fixed from latest release)"
|
description: "Release type"
|
||||||
required: false
|
required: false
|
||||||
default: "patch"
|
default: "patch"
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- patch
|
|
||||||
- minor
|
|
||||||
- major
|
- major
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
- dev
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -38,12 +41,70 @@ jobs:
|
|||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
EVENT_NAME: ${{ gitea.event_name }}
|
EVENT_NAME: ${{ gitea.event_name }}
|
||||||
REF_NAME: ${{ gitea.ref_name }}
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
BUMP_TYPE: ${{ gitea.event.inputs.bump || 'patch' }}
|
RELEASE_TYPE: ${{ gitea.event.inputs.release_type || 'patch' }}
|
||||||
DOCKER_API_VERSION: '1.44'
|
DOCKER_API_VERSION: '1.44'
|
||||||
run: |
|
run: |
|
||||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
latest_stable_tag() {
|
||||||
|
local releases_file="$1"
|
||||||
|
python3 -c "import json, sys; releases = json.load(open(sys.argv[1], encoding='utf-8')); print(next((release.get('tag_name', '').strip() for release in releases if not release.get('prerelease') and not release.get('draft')), ''))" "$releases_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
next_dev_suffix() {
|
||||||
|
local releases_file="$1"
|
||||||
|
local base_tag="$2"
|
||||||
|
python3 -c "import json, re, sys; releases_path, base_tag = sys.argv[1], sys.argv[2].strip(); pattern = re.compile(r'^' + re.escape(base_tag) + r'-dev\\.(\\d+)$'); releases = json.load(open(releases_path, encoding='utf-8')); highest = max([int(match.group(1)) for release in releases for match in [pattern.match(release.get('tag_name', '').strip())] if match], default=0); print(highest + 1)" "$releases_file" "$base_tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
make_dev_tag() {
|
||||||
|
local releases_file="$1"
|
||||||
|
local stable_tag="$2"
|
||||||
|
local major minor patch next_suffix
|
||||||
|
|
||||||
|
if [[ "$stable_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||||
|
major=${BASH_REMATCH[1]}
|
||||||
|
minor=${BASH_REMATCH[2]}
|
||||||
|
patch=${BASH_REMATCH[3]}
|
||||||
|
else
|
||||||
|
major=0
|
||||||
|
minor=8
|
||||||
|
patch=31
|
||||||
|
fi
|
||||||
|
|
||||||
|
patch=$((patch + 1))
|
||||||
|
next_suffix="$(next_dev_suffix "$releases_file" "v${major}.${minor}.${patch}")"
|
||||||
|
printf 'v%s.%s.%s-dev.%s' "$major" "$minor" "$patch" "$next_suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
releases_file="$(mktemp)"
|
||||||
|
trap 'rm -f "${releases_file:-}"' EXIT
|
||||||
|
|
||||||
|
if [ "$EVENT_NAME" = "push" ] && [ "$REF_NAME" = "main" ]; then
|
||||||
|
prerelease=true
|
||||||
|
if ! curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases" -o "$releases_file"; then
|
||||||
|
echo "Error: could not fetch release list"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
latest_tag="$(latest_stable_tag "$releases_file")"
|
||||||
|
if [ -z "$latest_tag" ]; then
|
||||||
|
latest_tag="v0.8.31"
|
||||||
|
fi
|
||||||
|
TAG="$(make_dev_tag "$releases_file" "$latest_tag")"
|
||||||
|
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_TYPE" = "dev" ]; then
|
||||||
|
prerelease=true
|
||||||
|
if ! curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases" -o "$releases_file"; then
|
||||||
|
echo "Error: could not fetch release list"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
latest_tag="$(latest_stable_tag "$releases_file")"
|
||||||
|
if [ -z "$latest_tag" ]; then
|
||||||
|
latest_tag="v0.8.31"
|
||||||
|
fi
|
||||||
|
TAG="$(make_dev_tag "$releases_file" "$latest_tag")"
|
||||||
|
elif [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||||
|
prerelease=false
|
||||||
TAG="$REF_NAME"
|
TAG="$REF_NAME"
|
||||||
else
|
else
|
||||||
|
prerelease=false
|
||||||
latest_tag="v0.8.31"
|
latest_tag="v0.8.31"
|
||||||
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases/latest" 2>/dev/null); then
|
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases/latest" 2>/dev/null); then
|
||||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||||
@@ -60,9 +121,9 @@ jobs:
|
|||||||
major=0; minor=8; patch=31
|
major=0; minor=8; patch=31
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${BUMP_TYPE:-}" = "major" ]; then
|
if [ "${RELEASE_TYPE:-}" = "major" ]; then
|
||||||
major=$((major + 1)); minor=0; patch=0
|
major=$((major + 1)); minor=0; patch=0
|
||||||
elif [ "${BUMP_TYPE:-}" = "minor" ]; then
|
elif [ "${RELEASE_TYPE:-}" = "minor" ]; then
|
||||||
minor=$((minor + 1)); patch=0
|
minor=$((minor + 1)); patch=0
|
||||||
else
|
else
|
||||||
patch=$((patch + 1))
|
patch=$((patch + 1))
|
||||||
@@ -109,6 +170,7 @@ jobs:
|
|||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||||
echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT"
|
echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Ensure Docker CLI is available and up to date
|
- name: Ensure Docker CLI is available and up to date
|
||||||
run: |
|
run: |
|
||||||
@@ -148,6 +210,7 @@ jobs:
|
|||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push release image
|
- name: Build and push release image
|
||||||
|
if: steps.meta.outputs.prerelease != 'true'
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -157,6 +220,16 @@ jobs:
|
|||||||
${{ steps.meta.outputs.image }}
|
${{ steps.meta.outputs.image }}
|
||||||
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
|
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
|
||||||
|
|
||||||
|
- name: Build and push prerelease image
|
||||||
|
if: steps.meta.outputs.prerelease == 'true'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image }}
|
||||||
|
|
||||||
- name: Create release-only docker bundle
|
- name: Create release-only docker bundle
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release-bundle
|
mkdir -p release-bundle
|
||||||
@@ -253,5 +326,6 @@ jobs:
|
|||||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.meta.outputs.tag }}
|
tag_name: ${{ steps.meta.outputs.tag }}
|
||||||
|
prerelease: ${{ steps.meta.outputs.prerelease }}
|
||||||
files: |
|
files: |
|
||||||
inventarsystem-docker-bundle.tar.gz
|
inventarsystem-docker-bundle.tar.gz
|
||||||
+2
-6
@@ -5177,17 +5177,13 @@ def upload_item():
|
|||||||
|
|
||||||
fs = get_gridfs()
|
fs = get_gridfs()
|
||||||
|
|
||||||
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
|
if cfg.MODULES.is_enabled('library') and sanitize_form_value(request.form.get('item_type_input', '')) != "other":
|
||||||
if can_access_admin_home:
|
success_redirect_endpoint = 'library'
|
||||||
success_redirect_endpoint = 'home_admin'
|
|
||||||
elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'):
|
|
||||||
success_redirect_endpoint = 'home_library'
|
|
||||||
else:
|
else:
|
||||||
success_redirect_endpoint = 'home_admin'
|
success_redirect_endpoint = 'home_admin'
|
||||||
|
|
||||||
# Detect if request is from mobile device
|
# Detect if request is from mobile device
|
||||||
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
||||||
is_ios = 'iPhone' in request.headers.get('User-Agent', '') or 'iPad' in request.headers.get('User-Agent', '')
|
|
||||||
|
|
||||||
# Log mobile request for debugging
|
# Log mobile request for debugging
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
|
|||||||
@@ -110,54 +110,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter 3 -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('3', 'Typ/Art') }} (Filter 3)</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=3) }}" class="mb-4">
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
|
|
||||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h5 class="mb-3">Vorhandene Werte</h5>
|
|
||||||
{% if filter3_values %}
|
|
||||||
<div class="list-group">
|
|
||||||
{% for value in filter3_values %}
|
|
||||||
<div class="list-group-item">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<span>{{ value }}</span>
|
|
||||||
<div>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-3-{{ loop.index }}')">Bearbeiten</button>
|
|
||||||
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=3, value=value) }}" class="d-inline">
|
|
||||||
<button type="submit" class="btn btn-sm btn-danger"
|
|
||||||
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
|
|
||||||
Entfernen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form id="edit-3-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=3, old_value=value) }}" style="display: none;" class="mt-2">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
|
|
||||||
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-3-{{ loop.index }}')">Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="alert alert-info">Keine Werte definiert.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
|
|||||||
+164
-57
@@ -761,6 +761,21 @@
|
|||||||
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
|
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
|
||||||
<form method="POST" action="{{ url_for('upload_item') }}" enctype="multipart/form-data">
|
<form method="POST" action="{{ url_for('upload_item') }}" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="upload_mode" value="{{ upload_mode|default('item') }}">
|
<input type="hidden" name="upload_mode" value="{{ upload_mode|default('item') }}">
|
||||||
|
|
||||||
|
{% if show_library_features %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="isbn">ISBN/Barcode (für Bild-Suche):</label>
|
||||||
|
<div class="isbn-input-group">
|
||||||
|
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
|
||||||
|
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
|
||||||
|
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen abrufen</button>
|
||||||
|
</div>
|
||||||
|
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
||||||
|
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
||||||
|
<div id="book-info-container" class="book-info-container"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Name:</label>
|
<label for="name">Name:</label>
|
||||||
<input type="text" id="name" name="name" required>
|
<input type="text" id="name" name="name" required>
|
||||||
@@ -785,6 +800,43 @@
|
|||||||
<textarea id="beschreibung" name="beschreibung" required></textarea>
|
<textarea id="beschreibung" name="beschreibung" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scan_mode">Erfassungs-Modus:</label>
|
||||||
|
<select id="scan_mode" name="scan_mode" class="form-control" onchange="toggleScanMode()">
|
||||||
|
<option value="single">Einzel-Code (Scanner stoppt nach 1 Scan) ↓ </option>
|
||||||
|
<option value="continuous">Fortlaufend scannen (Mehrere Codes nacheinander)</option>
|
||||||
|
<option value="range">Code-Bereich generieren (z.B. ABC-001 bis ABC-010)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="range_generator_group" class="form-group" style="display:none; background: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #ddd; margin-bottom: 15px;">
|
||||||
|
<label style="font-weight: bold;">Code-Bereich automatisch generieren:</label>
|
||||||
|
<div style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
|
||||||
|
<input type="text" id="range_prefix" placeholder="Präfix optional (z.B. IT-)" class="form-control" style="flex: 2;">
|
||||||
|
<input type="number" id="range_start" placeholder="Start (z.B. 1)" class="form-control" style="flex: 1;">
|
||||||
|
<span style="font-weight: bold;">bis</span>
|
||||||
|
<input type="text" id="range_end" placeholder="Ende (z.B. 020)" class="form-control" style="flex: 1;">
|
||||||
|
</div>
|
||||||
|
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Der Präfix ist optional. Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="primary_code_group">
|
||||||
|
<label for="code_4">Basis-Code</label>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<input type="text" id="code_4" name="code_4" class="form-control" placeholder="Haupt-Barcode" required>
|
||||||
|
<button type="button" id="scan-code4-btn" class="btn btn-primary">Barcode scannen</button>
|
||||||
|
</div>
|
||||||
|
<div id="code4-scanner" style="display:none; margin-top: 10px;"></div>
|
||||||
|
<small id="code4-scan-status" class="form-text text-muted"></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="individual_codes_group">
|
||||||
|
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
|
||||||
|
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B. ABC-001 ABC-002" class="form-control"></textarea>
|
||||||
|
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if show_library_features %}
|
{% if show_library_features %}
|
||||||
<!-- Library Mode: Single Customizable Filter -->
|
<!-- Library Mode: Single Customizable Filter -->
|
||||||
<div class="filter-inputs">
|
<div class="filter-inputs">
|
||||||
@@ -800,9 +852,9 @@
|
|||||||
</select>
|
</select>
|
||||||
<small style="display:block; color:#666;">Wählen Sie einen Medientyp aus zur Klassifizierung.</small>
|
<small style="display:block; color:#666;">Wählen Sie einen Medientyp aus zur Klassifizierung.</small>
|
||||||
</div>
|
</div>
|
||||||
<h3>Kategorie/Typ:</h3>
|
<h3>Kategorie/Typ/Fach:</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
|
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc. (optional)">
|
||||||
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
|
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -902,42 +954,6 @@
|
|||||||
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
||||||
<input id="anschaffungskosten" name="anschaffungskosten">
|
<input id="anschaffungskosten" name="anschaffungskosten">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="scan_mode">Erfassungs-Modus:</label>
|
|
||||||
<select id="scan_mode" name="scan_mode" class="form-control" onchange="toggleScanMode()">
|
|
||||||
<option value="single">Einzel-Code (Scanner stoppt nach 1 Scan)</option>
|
|
||||||
<option value="continuous">Fortlaufend scannen (Mehrere Codes nacheinander)</option>
|
|
||||||
<option value="range">Code-Bereich generieren (z.B. ABC-001 bis ABC-010)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="range_generator_group" class="form-group" style="display:none; background: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #ddd; margin-bottom: 15px;">
|
|
||||||
<label style="font-weight: bold;">Code-Bereich automatisch generieren:</label>
|
|
||||||
<div style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
|
|
||||||
<input type="text" id="range_prefix" placeholder="Präfix (z.B. IT-)" class="form-control" style="flex: 2;">
|
|
||||||
<input type="number" id="range_start" placeholder="Start (z.B. 1)" class="form-control" style="flex: 1;">
|
|
||||||
<span style="font-weight: bold;">bis</span>
|
|
||||||
<input type="text" id="range_end" placeholder="Ende (z.B. 020)" class="form-control" style="flex: 1;">
|
|
||||||
</div>
|
|
||||||
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" id="primary_code_group">
|
|
||||||
<label for="code_4">Basis-Code</label>
|
|
||||||
<div style="display: flex; gap: 10px;">
|
|
||||||
<input type="text" id="code_4" name="code_4" class="form-control" placeholder="Haupt-Barcode" required>
|
|
||||||
<button type="button" id="scan-code4-btn" class="btn btn-primary">Barcode scannen</button>
|
|
||||||
</div>
|
|
||||||
<div id="code4-scanner" style="display:none; margin-top: 10px;"></div>
|
|
||||||
<small id="code4-scan-status" class="form-text text-muted"></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" id="individual_codes_group">
|
|
||||||
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
|
|
||||||
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B. ABC-001 ABC-002" class="form-control"></textarea>
|
|
||||||
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
|
|
||||||
</div>
|
|
||||||
<!-- Image upload (hidden for library mode) -->
|
<!-- Image upload (hidden for library mode) -->
|
||||||
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
||||||
<label for="images">Bilder/Videos:</label>
|
<label for="images">Bilder/Videos:</label>
|
||||||
@@ -947,26 +963,11 @@
|
|||||||
<div class="image-preview-container" id="image-preview-container"></div>
|
<div class="image-preview-container" id="image-preview-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ISBN fields (library page only) -->
|
|
||||||
{% if show_library_features %}
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="isbn">ISBN/Barcode (für Bild-Suche):</label>
|
|
||||||
<div class="isbn-input-group">
|
|
||||||
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
|
|
||||||
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
|
|
||||||
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen abrufen</button>
|
|
||||||
</div>
|
|
||||||
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
|
||||||
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
|
||||||
<div id="book-info-container" class="book-info-container"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="reservierbar" style="display:inline-block; width:auto; margin-right:10px;">Reservierbar:</label>
|
<label for="reservierbar" style="display:inline-block; width:auto; margin-right:10px;">Reservierbar:</label>
|
||||||
<input type="checkbox" id="reservierbar" name="reservierbar" style="width:auto;">
|
<input type="checkbox" id="reservierbar" name="reservierbar" style="width:auto;">
|
||||||
<small style="display:block; color:#666;">Wenn deaktiviert, kann der Artikel nicht im Voraus reserviert werden (Sofort-Ausleihe bleibt möglich).</small>
|
<small style="display:block; color:#666;">Wenn deaktiviert, kann der Artikel nicht im Voraus reserviert werden (Sofort-Ausleihe bleibt möglich).</small>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="submit" class="submit-button">{{ 'Bücher hochladen' if show_library_features else 'Artikel hochladen' }}</button>
|
<button type="submit" class="submit-button">{{ 'Bücher hochladen' if show_library_features else 'Artikel hochladen' }}</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1023,6 +1024,90 @@
|
|||||||
<script>
|
<script>
|
||||||
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
||||||
|
|
||||||
|
function getFocusableFormFields(form) {
|
||||||
|
if (!form) return [];
|
||||||
|
return Array.from(form.querySelectorAll('input, select, textarea, button'))
|
||||||
|
.filter((element) => {
|
||||||
|
if (element.disabled || element.getAttribute('aria-hidden') === 'true') return false;
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const type = (element.type || '').toLowerCase();
|
||||||
|
return tagName !== 'button' || type !== 'button';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNextFormField(currentField) {
|
||||||
|
const form = currentField && currentField.form ? currentField.form : null;
|
||||||
|
const fields = getFocusableFormFields(form || document);
|
||||||
|
const currentIndex = fields.indexOf(currentField);
|
||||||
|
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
for (let i = currentIndex + 1; i < fields.length; i++) {
|
||||||
|
const nextField = fields[i];
|
||||||
|
const nextStyle = window.getComputedStyle(nextField);
|
||||||
|
if (nextStyle.display !== 'none' && nextStyle.visibility !== 'hidden') {
|
||||||
|
nextField.focus();
|
||||||
|
if (nextField.tagName.toLowerCase() === 'select') {
|
||||||
|
nextField.click();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
const submitButton = form.querySelector('button[type="submit"]');
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIsbnLookupFromScanner() {
|
||||||
|
const isbnField = document.getElementById('isbn');
|
||||||
|
if (!isbnField) return false;
|
||||||
|
|
||||||
|
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(isbnField.value) : isbnField.value.trim();
|
||||||
|
if (!normalizedIsbn) return false;
|
||||||
|
|
||||||
|
if (typeof updateIsbnLiveValidation === 'function') {
|
||||||
|
updateIsbnLiveValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fetchBookInfo === 'function') {
|
||||||
|
fetchBookInfo('upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('form').forEach(function (form) {
|
||||||
|
form.addEventListener('keydown', function (event) {
|
||||||
|
const target = event.target;
|
||||||
|
if (event.key !== 'Enter') return;
|
||||||
|
if (!target || target.tagName === 'TEXTAREA' && !event.shiftKey) return;
|
||||||
|
const tagName = target.tagName.toLowerCase();
|
||||||
|
const type = (target.type || '').toLowerCase();
|
||||||
|
if (!['input', 'select', 'textarea'].includes(tagName)) return;
|
||||||
|
if (['button', 'submit', 'reset', 'file', 'checkbox', 'radio', 'hidden'].includes(type)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (target.id === 'isbn' || target.name === 'isbn') {
|
||||||
|
handleIsbnLookupFromScanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusNextFormField(target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Function to check if a file is a video
|
// Function to check if a file is a video
|
||||||
function isVideoFile(filename) {
|
function isVideoFile(filename) {
|
||||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
|
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
|
||||||
@@ -1194,7 +1279,7 @@
|
|||||||
let generatedCodes = [];
|
let generatedCodes = [];
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
let numStr = i.toString().padStart(paddingLength, '0');
|
let numStr = i.toString().padStart(paddingLength, '0');
|
||||||
generatedCodes.push(`${prefix}${numStr}`);
|
generatedCodes.push(prefix ? `${prefix}${numStr}` : numStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeField = document.getElementById('code_4');
|
const codeField = document.getElementById('code_4');
|
||||||
@@ -1384,10 +1469,32 @@
|
|||||||
scanModeSelect.addEventListener('change', toggleScanMode);
|
scanModeSelect.addEventListener('change', toggleScanMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. ISBN Live-Validierung
|
// 4. ISBN Live-Validierung und automatische Abfrage nach Scan/Enter
|
||||||
const isbnInput = document.getElementById('isbn');
|
const isbnInput = document.getElementById('isbn');
|
||||||
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
|
if (isbnInput) {
|
||||||
isbnInput.addEventListener('input', updateIsbnLiveValidation);
|
if (typeof updateIsbnLiveValidation === 'function') {
|
||||||
|
isbnInput.addEventListener('input', updateIsbnLiveValidation);
|
||||||
|
}
|
||||||
|
|
||||||
|
let isbnLookupTimer = null;
|
||||||
|
isbnInput.addEventListener('input', function () {
|
||||||
|
clearTimeout(isbnLookupTimer);
|
||||||
|
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(isbnInput.value) : isbnInput.value.trim();
|
||||||
|
if (normalizedIsbn) {
|
||||||
|
isbnLookupTimer = setTimeout(function () {
|
||||||
|
handleIsbnLookupFromScanner();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
isbnInput.addEventListener('change', function () {
|
||||||
|
if (typeof normalizeIsbnClient === 'function') {
|
||||||
|
const normalizedIsbn = normalizeIsbnClient(isbnInput.value);
|
||||||
|
if (normalizedIsbn) {
|
||||||
|
handleIsbnLookupFromScanner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Load predefined filter values for dropdowns
|
// Load predefined filter values for dropdowns
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
<div class="user-management-container">
|
<div class="user-management-container">
|
||||||
<h2>Benutzer</h2>
|
<h2>Benutzer</h2>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{{ url_for('register') }}" class="btn btn-success">
|
||||||
|
Neuen Benutzer registrieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
|
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
+260
-4
@@ -12,6 +12,250 @@ fi
|
|||||||
# Resolve script directory so config paths are deterministic even when called via sudo
|
# Resolve script directory so config paths are deterministic even when called via sudo
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||||
|
NGINX_DOMAIN_BASE="${INVENTAR_TENANT_DOMAIN_BASE:-invario-software.de}"
|
||||||
|
NGINX_PROXY_HOST="${INVENTAR_NGINX_PROXY_HOST:-172.17.0.1}"
|
||||||
|
NGINX_SITES_AVAILABLE="${INVENTAR_NGINX_SITES_AVAILABLE:-/etc/nginx/sites-available}"
|
||||||
|
NGINX_SITES_ENABLED="${INVENTAR_NGINX_SITES_ENABLED:-/etc/nginx/sites-enabled}"
|
||||||
|
NGINX_SITE_PREFIX="${INVENTAR_NGINX_SITE_PREFIX:-inventarsystem}"
|
||||||
|
CERTBOT_EMAIL="${INVENTAR_CERTBOT_EMAIL:-}"
|
||||||
|
|
||||||
|
tenant_domain() {
|
||||||
|
printf '%s.%s' "$1" "$NGINX_DOMAIN_BASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant_nginx_site_path() {
|
||||||
|
printf '%s/%s-%s.conf' "$NGINX_SITES_AVAILABLE" "$NGINX_SITE_PREFIX" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant_nginx_enabled_path() {
|
||||||
|
printf '%s/%s-%s.conf' "$NGINX_SITES_ENABLED" "$NGINX_SITE_PREFIX" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_host_command() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo "$@"
|
||||||
|
else
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
reload_host_nginx() {
|
||||||
|
if command -v nginx >/dev/null 2>&1 || command -v sudo >/dev/null 2>&1; then
|
||||||
|
if ! run_host_command nginx -t; then
|
||||||
|
echo "Warning: nginx configuration test failed; skipping reload."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
if ! run_host_command systemctl reload nginx; then
|
||||||
|
echo "Warning: systemctl reload nginx failed."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! run_host_command nginx -s reload; then
|
||||||
|
echo "Warning: nginx reload failed."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_tenant_nginx_config() {
|
||||||
|
local tenant_id="$1"
|
||||||
|
local port="$2"
|
||||||
|
local domain site_path enabled_path
|
||||||
|
|
||||||
|
domain="$(tenant_domain "$tenant_id")"
|
||||||
|
site_path="$(tenant_nginx_site_path "$tenant_id")"
|
||||||
|
enabled_path="$(tenant_nginx_enabled_path "$tenant_id")"
|
||||||
|
|
||||||
|
if [ -z "$port" ]; then
|
||||||
|
echo "Warning: No port configured for tenant '$tenant_id'; skipping nginx vhost creation."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! run_host_command mkdir -p "$NGINX_SITES_AVAILABLE" "$NGINX_SITES_ENABLED"; then
|
||||||
|
echo "Warning: Unable to prepare nginx site directories; skipping reverse-proxy setup."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local have_cert=false
|
||||||
|
|
||||||
|
if [ -f "/etc/letsencrypt/live/$domain/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$domain/privkey.pem" ]; then
|
||||||
|
have_cert=true
|
||||||
|
else
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
cat > "$site_path" <<EOF
|
||||||
|
# ==========================================
|
||||||
|
# INVENTARSYSTEM ($tenant_id)
|
||||||
|
# ==========================================
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name $domain;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://$NGINX_PROXY_HOST:$port;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
sudo tee "$site_path" >/dev/null <<EOF
|
||||||
|
# ==========================================
|
||||||
|
# INVENTARSYSTEM ($tenant_id)
|
||||||
|
# ==========================================
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name $domain;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://$NGINX_PROXY_HOST:$port;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! run_host_command ln -sfn "$site_path" "$enabled_path"; then
|
||||||
|
echo "Warning: Could not enable nginx site for tenant '$tenant_id'."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! reload_host_nginx; then
|
||||||
|
echo "Warning: Initial nginx reload failed for tenant '$tenant_id'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v certbot >/dev/null 2>&1 || command -v sudo >/dev/null 2>&1; then
|
||||||
|
local certbot_args=(certbot --nginx --non-interactive --agree-tos -d "$domain")
|
||||||
|
if [ -n "$CERTBOT_EMAIL" ]; then
|
||||||
|
certbot_args+=(--email "$CERTBOT_EMAIL")
|
||||||
|
else
|
||||||
|
certbot_args+=(--register-unsafely-without-email)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! run_host_command "${certbot_args[@]}"; then
|
||||||
|
echo "Warning: certbot failed for tenant '$tenant_id'; keeping HTTP-only vhost for now."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
have_cert=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$have_cert" != true ]; then
|
||||||
|
echo "Warning: No TLS certificate available for tenant '$tenant_id'; leaving HTTP-only nginx config in place."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
cat > "$site_path" <<EOF
|
||||||
|
# ==========================================
|
||||||
|
# INVENTARSYSTEM ($tenant_id)
|
||||||
|
# ==========================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name $domain;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://$NGINX_PROXY_HOST:$port;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gezielte HTTP zu HTTPS Weiterleitung für $tenant_id
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name $domain;
|
||||||
|
return 301 https://$domain\$request_uri;
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
sudo tee "$site_path" >/dev/null <<EOF
|
||||||
|
# ==========================================
|
||||||
|
# INVENTARSYSTEM ($tenant_id)
|
||||||
|
# ==========================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name $domain;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://$NGINX_PROXY_HOST:$port;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gezielte HTTP zu HTTPS Weiterleitung für $tenant_id
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name $domain;
|
||||||
|
return 301 https://$domain\$request_uri;
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! run_host_command ln -sfn "$site_path" "$enabled_path"; then
|
||||||
|
echo "Warning: Could not enable nginx site for tenant '$tenant_id'."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! reload_host_nginx; then
|
||||||
|
echo "Warning: nginx reload after tenant '$tenant_id' configuration update failed."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_tenant_nginx_config() {
|
||||||
|
local tenant_id="$1"
|
||||||
|
local domain site_path enabled_path
|
||||||
|
|
||||||
|
domain="$(tenant_domain "$tenant_id")"
|
||||||
|
site_path="$(tenant_nginx_site_path "$tenant_id")"
|
||||||
|
enabled_path="$(tenant_nginx_enabled_path "$tenant_id")"
|
||||||
|
|
||||||
|
if [ -f "$enabled_path" ] || [ -L "$enabled_path" ]; then
|
||||||
|
run_host_command rm -f "$enabled_path" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$site_path" ]; then
|
||||||
|
run_host_command rm -f "$site_path" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v certbot >/dev/null 2>&1 || command -v sudo >/dev/null 2>&1; then
|
||||||
|
run_host_command certbot delete --cert-name "$domain" --non-interactive >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
reload_host_nginx || true
|
||||||
|
}
|
||||||
|
|
||||||
ensure_runtime_config_json() {
|
ensure_runtime_config_json() {
|
||||||
local config_path backup_path
|
local config_path backup_path
|
||||||
@@ -505,15 +749,18 @@ ${YELLOW}Nutzung:${RESET} $0 <befehl> [tenant_id] [optionen]
|
|||||||
|
|
||||||
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
|
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
|
||||||
${GREEN}add${RESET} <tenant_id> [port]
|
${GREEN}add${RESET} <tenant_id> [port]
|
||||||
Legt einen neuen Tenant an, registriert den Port und initialisiert
|
Legt einen neuen Tenant an, registriert den Port, richtet den nginx-Host
|
||||||
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}).
|
für ${YELLOW}<tenant_id>.invario-software.de${RESET} ein und initialisiert
|
||||||
|
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}).
|
||||||
|
|
||||||
${GREEN}trial${RESET} <tenant_id> [port] [tage]
|
${GREEN}trial${RESET} <tenant_id> [port] [tage]
|
||||||
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage.
|
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage.
|
||||||
Läuft automatisch ab und löscht sich selbst.
|
Läuft automatisch ab, löscht sich selbst und bekommt die gleiche nginx/
|
||||||
|
Certbot-Anbindung wie ein normaler Tenant.
|
||||||
|
|
||||||
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
|
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
|
||||||
Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank.
|
Löscht einen Tenant, seine Konfiguration, Ports, nginx-VHost und das
|
||||||
|
zugehörige Let's-Encrypt-Zertifikat sowie die Datenbank.
|
||||||
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
|
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
|
||||||
|
|
||||||
${GREEN}restart-tenant${RESET} <tenant_id>
|
${GREEN}restart-tenant${RESET} <tenant_id>
|
||||||
@@ -574,11 +821,16 @@ case "$COMMAND" in
|
|||||||
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
||||||
update_runtime_ports "$PORT_ARG"
|
update_runtime_ports "$PORT_ARG"
|
||||||
sync_tenant_port_map
|
sync_tenant_port_map
|
||||||
|
if ! write_tenant_nginx_config "$TENANT_ID" "$PORT_ARG"; then
|
||||||
|
echo "Warning: nginx/certbot provisioning for '$TENANT_ID' could not be completed automatically."
|
||||||
|
fi
|
||||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||||
restart_app_container
|
restart_app_container
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
echo "Adding new tenant '$TENANT_ID'..."
|
echo "Adding new tenant '$TENANT_ID'..."
|
||||||
echo "Initializing database for $TENANT_ID..."
|
echo "Initializing database for $TENANT_ID..."
|
||||||
initialize_tenant_database "$TENANT_ID" "standard"
|
initialize_tenant_database "$TENANT_ID" "standard"
|
||||||
@@ -603,6 +855,9 @@ case "$COMMAND" in
|
|||||||
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
||||||
update_runtime_ports "$PORT_ARG"
|
update_runtime_ports "$PORT_ARG"
|
||||||
sync_tenant_port_map
|
sync_tenant_port_map
|
||||||
|
if ! write_tenant_nginx_config "$TENANT_ID" "$PORT_ARG"; then
|
||||||
|
echo "Warning: nginx/certbot provisioning for trial tenant '$TENANT_ID' could not be completed automatically."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
|
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
|
||||||
@@ -682,6 +937,7 @@ case "$COMMAND" in
|
|||||||
if [ -n "$port_to_remove" ]; then
|
if [ -n "$port_to_remove" ]; then
|
||||||
remove_runtime_port "$port_to_remove"
|
remove_runtime_port "$port_to_remove"
|
||||||
fi
|
fi
|
||||||
|
remove_tenant_nginx_config "$TENANT_ID"
|
||||||
sync_tenant_port_map
|
sync_tenant_port_map
|
||||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||||
restart_app_container
|
restart_app_container
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ LOG_DIR="$PROJECT_DIR/logs"
|
|||||||
LOG_FILE="$LOG_DIR/update.log"
|
LOG_FILE="$LOG_DIR/update.log"
|
||||||
STATE_FILE="$PROJECT_DIR/.release-version"
|
STATE_FILE="$PROJECT_DIR/.release-version"
|
||||||
REPO_SLUG="Invario/Inventarsystem"
|
REPO_SLUG="Invario/Inventarsystem"
|
||||||
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
|
API_BASE_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG"
|
||||||
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
||||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
|
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
|
||||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
||||||
MODE="release"
|
MODE="stable"
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||||
@@ -119,6 +119,7 @@ usage() {
|
|||||||
Usage: $0 [options]
|
Usage: $0 [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
-dev Install the latest prerelease build
|
||||||
--multitenant Use docker-compose-multitenant.yml (default)
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
@@ -127,6 +128,10 @@ EOF
|
|||||||
parse_args() {
|
parse_args() {
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
-dev)
|
||||||
|
MODE="development"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--multitenant)
|
--multitenant)
|
||||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
shift
|
shift
|
||||||
@@ -179,7 +184,8 @@ create_backup() {
|
|||||||
|
|
||||||
fetch_release_metadata() {
|
fetch_release_metadata() {
|
||||||
local meta_file="$1"
|
local meta_file="$1"
|
||||||
curl -fsSL "$API_URL" -o "$meta_file"
|
local endpoint="${2:-latest}"
|
||||||
|
curl -fsSL "$API_BASE_URL/releases/$endpoint" -o "$meta_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_latest_tag() {
|
parse_latest_tag() {
|
||||||
@@ -207,6 +213,124 @@ for asset in data.get('assets', []):
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parse_release_tag() {
|
||||||
|
local meta_file="$1"
|
||||||
|
python3 - <<'PY' "$meta_file"
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print(data.get('tag_name', '').strip())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
strip_prerelease_suffix() {
|
||||||
|
local tag="$1"
|
||||||
|
python3 - <<'PY' "$tag"
|
||||||
|
import re, sys
|
||||||
|
tag = sys.argv[1].strip()
|
||||||
|
match = re.match(r'^(v\d+\.\d+\.\d+)', tag)
|
||||||
|
print(match.group(1) if match else tag)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
compare_semver_tags() {
|
||||||
|
local left_tag="$1"
|
||||||
|
local right_tag="$2"
|
||||||
|
python3 - <<'PY' "$left_tag" "$right_tag"
|
||||||
|
import re, sys
|
||||||
|
|
||||||
|
def parse(tag):
|
||||||
|
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)', tag.strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return tuple(int(part) for part in match.groups())
|
||||||
|
|
||||||
|
left = parse(sys.argv[1])
|
||||||
|
right = parse(sys.argv[2])
|
||||||
|
if left is None or right is None:
|
||||||
|
print('unknown')
|
||||||
|
elif left > right:
|
||||||
|
print('gt')
|
||||||
|
elif left < right:
|
||||||
|
print('lt')
|
||||||
|
else:
|
||||||
|
print('eq')
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_prerelease_flag() {
|
||||||
|
local meta_file="$1"
|
||||||
|
python3 - <<'PY' "$meta_file"
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print('true' if data.get('prerelease') else 'false')
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_stable_release() {
|
||||||
|
local meta_file="$1"
|
||||||
|
local releases_file="$2"
|
||||||
|
local release_count=0
|
||||||
|
|
||||||
|
if ! curl -fsSL "$API_BASE_URL/releases" -o "$releases_file"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 - <<'PY' "$releases_file" "$meta_file"
|
||||||
|
import json, sys
|
||||||
|
releases_path, target_path = sys.argv[1], sys.argv[2]
|
||||||
|
with open(releases_path, 'r', encoding='utf-8') as f:
|
||||||
|
releases = json.load(f)
|
||||||
|
for release in releases:
|
||||||
|
if not release.get('prerelease') and not release.get('draft'):
|
||||||
|
with open(target_path, 'w', encoding='utf-8') as out:
|
||||||
|
json.dump(release, out)
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_prerelease() {
|
||||||
|
local meta_file="$1"
|
||||||
|
local releases_file="$2"
|
||||||
|
|
||||||
|
if ! curl -fsSL "$API_BASE_URL/releases" -o "$releases_file"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 - <<'PY' "$releases_file" "$meta_file"
|
||||||
|
import json, sys
|
||||||
|
releases_path, target_path = sys.argv[1], sys.argv[2]
|
||||||
|
with open(releases_path, 'r', encoding='utf-8') as f:
|
||||||
|
releases = json.load(f)
|
||||||
|
for release in releases:
|
||||||
|
if release.get('prerelease') and not release.get('draft'):
|
||||||
|
with open(target_path, 'w', encoding='utf-8') as out:
|
||||||
|
json.dump(release, out)
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_latest_release_bundle() {
|
||||||
|
local release_meta="$1"
|
||||||
|
local bundle_url
|
||||||
|
bundle_url="$(parse_asset_url "$release_meta" "$BUNDLE_ASSET")"
|
||||||
|
if [ -z "$bundle_url" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$bundle_url"
|
||||||
|
}
|
||||||
|
|
||||||
refresh_runtime_scripts_from_main() {
|
refresh_runtime_scripts_from_main() {
|
||||||
local start_url stop_url restart_url update_url
|
local start_url stop_url restart_url update_url
|
||||||
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
|
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
|
||||||
@@ -368,12 +492,66 @@ main() {
|
|||||||
archive_logs
|
archive_logs
|
||||||
create_backup
|
create_backup
|
||||||
|
|
||||||
# If user requested a development install, perform a simple dev deploy flow
|
# If user requested a development install, deploy the latest prerelease.
|
||||||
if [ "$MODE" = "development" ]; then
|
if [ "$MODE" = "development" ]; then
|
||||||
log_message "Requested development install"
|
log_message "Requested development install"
|
||||||
local tag="dev"
|
local tag meta_file releases_file stable_meta_file stable_tag prerelease_tag prerelease_base_tag bundle_url app_image compose_path
|
||||||
local app_image="$APP_IMAGE_REPO:$tag"
|
local tmp_dir
|
||||||
local compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
meta_file="$tmp_dir/prerelease.json"
|
||||||
|
stable_meta_file="$tmp_dir/stable.json"
|
||||||
|
releases_file="$tmp_dir/releases.json"
|
||||||
|
trap 'rm -rf "${tmp_dir:-}"' EXIT
|
||||||
|
|
||||||
|
if ! fetch_release_metadata "$stable_meta_file" "latest"; then
|
||||||
|
log_message "ERROR: Could not fetch latest stable release metadata"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
stable_tag="$(parse_release_tag "$stable_meta_file")"
|
||||||
|
if [ -z "$stable_tag" ]; then
|
||||||
|
log_message "ERROR: Could not determine latest stable release tag"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if latest_prerelease "$meta_file" "$releases_file"; then
|
||||||
|
prerelease_tag="$(parse_release_tag "$meta_file")"
|
||||||
|
prerelease_base_tag="$(strip_prerelease_suffix "$prerelease_tag")"
|
||||||
|
else
|
||||||
|
prerelease_tag=""
|
||||||
|
prerelease_base_tag=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$prerelease_tag" ]; then
|
||||||
|
case "$(compare_semver_tags "$stable_tag" "$prerelease_base_tag")" in
|
||||||
|
gt|eq)
|
||||||
|
log_message "Latest stable release ($stable_tag) supersedes prerelease ($prerelease_tag); using stable release"
|
||||||
|
tag="$stable_tag"
|
||||||
|
meta_file="$stable_meta_file"
|
||||||
|
;;
|
||||||
|
lt)
|
||||||
|
tag="$prerelease_tag"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_message "WARNING: Could not compare stable and prerelease versions; preferring prerelease when available"
|
||||||
|
tag="$prerelease_tag"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
log_message "No prerelease found; falling back to stable release $stable_tag"
|
||||||
|
tag="$stable_tag"
|
||||||
|
meta_file="$stable_meta_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bundle_url="$(fetch_latest_release_bundle "$meta_file")"
|
||||||
|
if [ -z "$bundle_url" ]; then
|
||||||
|
log_message "ERROR: Release asset not found: $BUNDLE_ASSET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
app_image="$APP_IMAGE_REPO:$tag"
|
||||||
|
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
|
||||||
if [ ! -f "$compose_path" ]; then
|
if [ ! -f "$compose_path" ]; then
|
||||||
log_message "ERROR: compose file not found: $compose_path"
|
log_message "ERROR: compose file not found: $compose_path"
|
||||||
@@ -399,6 +577,9 @@ EOF
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
download_and_extract_bundle "$bundle_url" "$tmp_dir"
|
||||||
|
refresh_runtime_scripts_from_main
|
||||||
|
|
||||||
# Bring up stack
|
# Bring up stack
|
||||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true
|
||||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||||
@@ -409,7 +590,7 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$tag" > "$STATE_FILE"
|
echo "$tag" > "$STATE_FILE"
|
||||||
log_message "Development update completed successfully"
|
log_message "Development update completed successfully to prerelease $tag"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -419,8 +600,8 @@ EOF
|
|||||||
|
|
||||||
trap 'rm -rf "${tmp_dir:-}"' EXIT
|
trap 'rm -rf "${tmp_dir:-}"' EXIT
|
||||||
|
|
||||||
log_message "Checking latest Gitea release for $REPO_SLUG..."
|
log_message "Checking latest stable Gitea release for $REPO_SLUG..."
|
||||||
if ! fetch_release_metadata "$meta_file"; then
|
if ! fetch_release_metadata "$meta_file" "latest"; then
|
||||||
log_message "WARNING: Could not fetch release metadata. Falling back to self-healing start path."
|
log_message "WARNING: Could not fetch release metadata. Falling back to self-healing start path."
|
||||||
if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then
|
if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then
|
||||||
if verify_stack_health; then
|
if verify_stack_health; then
|
||||||
@@ -436,7 +617,7 @@ EOF
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
latest_tag="$(parse_latest_tag "$meta_file")"
|
latest_tag="$(parse_release_tag "$meta_file")"
|
||||||
if [ -z "$latest_tag" ]; then
|
if [ -z "$latest_tag" ]; then
|
||||||
log_message "WARNING: Could not determine latest release tag. Falling back to self-healing start path."
|
log_message "WARNING: Could not determine latest release tag. Falling back to self-healing start path."
|
||||||
if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then
|
if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then
|
||||||
|
|||||||
Reference in New Issue
Block a user