Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20528b60c5 | |||
| c610c06a0e | |||
| 2cef1672d2 | |||
| eb8542c410 | |||
| 1ebd83ec2c | |||
| e80bf946c0 | |||
| bfbbff597e | |||
| d80f3f56b7 | |||
| 726fca0866 | |||
| 614f73250a | |||
| 0d2488eb0b | |||
| 7e6a1d6b03 | |||
| ebf144f1a9 | |||
| 78fda8d569 | |||
| 248594ce0d | |||
| 783c68a243 | |||
| 031d41643e | |||
| cae3199e53 | |||
| d0f2afae57 | |||
| 1a99448a2e | |||
| aec25a13b9 | |||
| b31abe2177 | |||
| 7fd27e8c02 | |||
| 5f5784e7c3 | |||
| 613c1e11e4 | |||
| 9e3b54c1b1 | |||
| a8bce26eab | |||
| 36c391e99c | |||
| 40c3970719 | |||
| e7f4b1ce9d | |||
| d84466a3cc | |||
| 0efc01a835 | |||
| 73cf63a56d | |||
| fdcd26326b | |||
| 1fd9e230ac | |||
| cc0c4e28b7 | |||
| ea73b98549 | |||
| d6dda0f25a | |||
| 46176c1741 | |||
| c57b4a9a44 | |||
| 6f424cc8aa | |||
| 103b82af15 | |||
| 69c8aa8700 | |||
| 1fa3dace17 | |||
| 6dced8b1a5 | |||
| 493ee2ea7f | |||
| 52a2ec0cad | |||
| b5f2c3c5c5 | |||
| 403c281c93 | |||
| 6d121f6110 | |||
| 9d940c6151 | |||
| 980b825e07 | |||
| 94326728eb | |||
| efe17883a6 | |||
| 76f93b3d2e | |||
| 8f793045c2 | |||
| a199439d76 | |||
| 6cec483390 | |||
| 10e2f7cc17 | |||
| 0f8c5a0fec | |||
| 091147ecfd | |||
| 7bedfc1558 | |||
| 7e258e07ab | |||
| 96245bb06b | |||
| 245c3c19dd | |||
| d76c69d836 | |||
| 9527fc10cf | |||
| 1122618a51 | |||
| 54d56fc463 |
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+302
-213
File diff suppressed because it is too large
Load Diff
@@ -1235,5 +1235,5 @@ def reset_item_completely(item_id):
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Fehler beim Zurücksetzen: {str(e)}'
|
||||
'message': f'Fehler beim Zurücksetzen.'
|
||||
}
|
||||
@@ -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')
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from Web.modules.inventarsystem.data_protection import encrypt_document_fields, decrypt_document_fields
|
||||
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
@@ -24,21 +25,34 @@ def _entry_hash(prev_hash, payload):
|
||||
base = f"{prev_hash}|{_stable_json(payload)}"
|
||||
return hashlib.sha256(base.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
|
||||
def get_decrypted_audit_logs(db, query=None, decrypt_fields=None):
|
||||
"""
|
||||
Append an audit event to a tamper-evident chain.
|
||||
|
||||
Retrieve and decrypt audit logs for analysis.
|
||||
|
||||
Args:
|
||||
db: MongoDB database handle.
|
||||
event_type (str): Event category.
|
||||
actor (str): User/system who performed the action.
|
||||
payload (dict): Event details.
|
||||
request_ip (str, optional): Request origin.
|
||||
source (str): Source subsystem.
|
||||
query (dict): MongoDB query filter.
|
||||
decrypt_fields (list): Fields within the 'payload' that should be decrypted.
|
||||
"""
|
||||
logs = db["audit_log"]
|
||||
cursor = logs.find(query or {}).sort("chain_index", 1)
|
||||
|
||||
results = []
|
||||
for entry in cursor:
|
||||
# Decrypt specific fields if provided
|
||||
if decrypt_fields:
|
||||
decrypt_document_fields(entry.get("payload", {}), decrypt_fields)
|
||||
results.append(entry)
|
||||
|
||||
return results
|
||||
|
||||
Returns:
|
||||
dict: Inserted audit entry.
|
||||
|
||||
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5, encrypt_fields=None):
|
||||
"""
|
||||
Append an audit event, optionally encrypting specific payload fields.
|
||||
|
||||
Args:
|
||||
...
|
||||
encrypt_fields (list, optional): List of keys in 'payload' to encrypt.
|
||||
"""
|
||||
logs = db["audit_log"]
|
||||
attempts = 0
|
||||
@@ -49,15 +63,24 @@ def append_audit_event(db, event_type, actor, payload, request_ip=None, source="
|
||||
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
|
||||
|
||||
timestamp = datetime.datetime.utcnow()
|
||||
|
||||
# 1. Create the payload dictionary
|
||||
event_payload = payload or {}
|
||||
|
||||
# 2. Encrypt sensitive fields in-place if requested
|
||||
if encrypt_fields:
|
||||
encrypt_document_fields(event_payload, encrypt_fields)
|
||||
|
||||
entry_payload = {
|
||||
"event_type": event_type,
|
||||
"actor": actor or "system",
|
||||
"source": source,
|
||||
"ip": request_ip or "",
|
||||
"payload": payload or {},
|
||||
"payload": event_payload,
|
||||
"timestamp": timestamp.isoformat() + "Z",
|
||||
}
|
||||
|
||||
# 3. Hash the payload (which now contains encrypted values)
|
||||
entry_hash = _entry_hash(prev_hash, entry_payload)
|
||||
|
||||
entry = {
|
||||
|
||||
@@ -21,7 +21,7 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
||||
ausleihung_id: Die ID der Ausleihung
|
||||
old_status: Der alte Status
|
||||
new_status: Der neue Status
|
||||
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
|
||||
|
||||
"""
|
||||
try:
|
||||
# Erstelle Log-Verzeichnis, falls es nicht existiert
|
||||
@@ -34,10 +34,9 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
||||
|
||||
# Protokolliere die Änderung
|
||||
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
user_info = f" by {user}" if user else ""
|
||||
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n")
|
||||
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'\n")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,7 @@ import Web.modules.terminplaner.backend_server as appointment_service
|
||||
import Web.modules.database.settings as cfg
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.user as us
|
||||
from Web.modules.terminplaner.backend_server import _resolve_public_base_url
|
||||
import csv
|
||||
import io
|
||||
import qrcode
|
||||
@@ -214,7 +215,8 @@ def client(appointment_id):
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields
|
||||
custom_fields=custom_fields,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||
@@ -238,7 +240,8 @@ def client(appointment_id):
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields,
|
||||
appointment_item=appointment_item
|
||||
appointment_item=appointment_item,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
|
||||
@@ -355,6 +358,7 @@ def configure():
|
||||
add_to_calendar=add_to_calendar,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=title,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
return render_template(
|
||||
@@ -365,6 +369,7 @@ def configure():
|
||||
add_to_calendar=False,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=None,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
|
||||
|
||||
@@ -415,16 +420,29 @@ def export_pdf_brief(plan_id):
|
||||
address = school_info.get('address', 'Adresse')
|
||||
postal_code = school_info.get('postal_code', 'PLZ')
|
||||
city = school_info.get('city', 'Stadt')
|
||||
|
||||
school_number = school_info.get('school_number', '000000')
|
||||
it_admin = school_info.get('it_admin', 'IT-Beauftragter/in')
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=plan_id, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
link = host + "/terminplaner/client/" + plan_id
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
|
||||
schul_daten = {
|
||||
"schulname": school_name,
|
||||
"strasse": address,
|
||||
"plz_ort": f"{postal_code} {city}"
|
||||
"plz_ort": f"{postal_code} {city}",
|
||||
"schulnummer": school_number,
|
||||
"it_admin": it_admin
|
||||
}
|
||||
|
||||
plan_daten = {
|
||||
"titel": termin.get_item(plan_id).get('title', 'Terminplan'), # terminplan.title
|
||||
"link": termin.get_item(plan_id).get('link', ''), # terminplan.link
|
||||
"link": link, # terminplan.link
|
||||
"notizen": termin.get_item(plan_id).get('note', '') # terminplan.note
|
||||
}
|
||||
|
||||
@@ -471,7 +489,7 @@ def export_pdf_brief(plan_id):
|
||||
# Datum (Hier statisch zum Test, ggf. dynamisch per datetime)
|
||||
import datetime
|
||||
heute = datetime.datetime.now().strftime("%d.%m.%Y")
|
||||
elements.append(Paragraph(f"München, den {heute}", styles['Date']))
|
||||
elements.append(Paragraph(f"{schul_daten['plz_ort']}, den {heute}", styles['Date']))
|
||||
elements.append(Spacer(1, 1*cm))
|
||||
|
||||
# Betreff
|
||||
@@ -484,7 +502,7 @@ def export_pdf_brief(plan_id):
|
||||
|
||||
# Link
|
||||
elements.append(Paragraph("<b>Ihr persönlicher Buchungslink:</b>", styles['Body']))
|
||||
link_html = f'<a href="{plan_daten["link"]}" color="#16697a">{plan_daten["link"]}</a>'
|
||||
link_html = f'<a href="{plan_daten["link"]}?" color="#16697a">{plan_daten["link"]}</a>'
|
||||
elements.append(Paragraph(link_html, styles['Body']))
|
||||
|
||||
# Das vorhin erstellte QR-Code Bild einfügen
|
||||
@@ -539,4 +557,5 @@ def main():
|
||||
current_user=current_user,
|
||||
upcoming_events=upcoming_events,
|
||||
tenant_id=tenant_id,
|
||||
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||
)
|
||||
+71
-110
@@ -13,6 +13,7 @@ import logging
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from Web.modules.inventarsystem.data_protection import encrypt_text, decrypt_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,16 +45,22 @@ if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY:
|
||||
serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
VAPID_PUBLIC_KEY = b64urlencode(raw_pub)
|
||||
VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8')
|
||||
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
|
||||
except Exception as e:
|
||||
logger.error(f'Could not load or generate VAPID keys: {e}')
|
||||
|
||||
# Push service endpoint (typically Firebase or Web Push Service)
|
||||
PUSH_SERVICE_URL = 'https://fcm.googleapis.com/fcm/send' # Firebase Cloud Messaging
|
||||
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
|
||||
|
||||
|
||||
def _get_username_hash(username):
|
||||
"""Generates a deterministic hash for database lookups."""
|
||||
if not username:
|
||||
return None
|
||||
return hashlib.sha256(username.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def get_push_subscriptions_collection(db=None):
|
||||
"""Get MongoDB push subscriptions collection"""
|
||||
if db is None:
|
||||
@@ -64,71 +71,68 @@ def get_push_subscriptions_collection(db=None):
|
||||
|
||||
def get_user_subscriptions(username):
|
||||
"""
|
||||
Get all active push subscriptions for a user
|
||||
|
||||
Args:
|
||||
username (str): Username
|
||||
|
||||
Returns:
|
||||
list: List of subscription documents
|
||||
Get all active push subscriptions for a user, decrypting data on the fly.
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
subscriptions = list(subs_col.find({
|
||||
'Username': username,
|
||||
# Query using the deterministic hash, NOT the encrypted text directly
|
||||
user_hash = _get_username_hash(username)
|
||||
|
||||
encrypted_subscriptions = list(subs_col.find({
|
||||
'UsernameHash': user_hash,
|
||||
'IsActive': True
|
||||
}))
|
||||
|
||||
client.close()
|
||||
return subscriptions
|
||||
|
||||
# Decrypt endpoints and keys before returning
|
||||
decrypted_subs = []
|
||||
for sub in encrypted_subscriptions:
|
||||
try:
|
||||
sub['Endpoint'] = decrypt_text(sub.get('Endpoint'))
|
||||
|
||||
# Keys are stored as encrypted JSON strings
|
||||
decrypted_keys_str = decrypt_text(sub.get('Keys'))
|
||||
sub['Keys'] = json.loads(decrypted_keys_str) if decrypted_keys_str else {}
|
||||
|
||||
decrypted_subs.append(sub)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt subscription payload for hash {user_hash}: {e}")
|
||||
|
||||
return decrypted_subs
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting push subscriptions for {username}: {e}')
|
||||
logger.error(f'Error getting push subscriptions for user: {e}')
|
||||
return []
|
||||
|
||||
|
||||
def save_push_subscription(username, subscription_obj):
|
||||
"""
|
||||
Save a new push subscription for a user
|
||||
|
||||
Args:
|
||||
username (str): Username
|
||||
subscription_obj (dict): Subscription object from Service Worker
|
||||
{
|
||||
'endpoint': 'https://...',
|
||||
'keys': {
|
||||
'p256dh': '...',
|
||||
'auth': '...'
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
bool: Success status
|
||||
Save a new push subscription for a user with field-level encryption.
|
||||
"""
|
||||
try:
|
||||
if not subscription_obj.get('endpoint'):
|
||||
logger.warning(f'Invalid subscription object for {username}')
|
||||
endpoint = subscription_obj.get('endpoint')
|
||||
if not endpoint:
|
||||
logger.warning('Invalid subscription object: missing endpoint')
|
||||
return False
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
# Create unique hash of subscription to avoid duplicates
|
||||
sub_hash = hashlib.md5(
|
||||
f"{username}:{subscription_obj['endpoint']}".encode()
|
||||
# Create unique hash of subscription using plaintext data to avoid duplicates
|
||||
sub_hash = hashlib.shake_256(
|
||||
f"{username}:{endpoint}".encode('utf-8')
|
||||
).hexdigest()
|
||||
|
||||
# Check if subscription already exists
|
||||
# Check if subscription already exists by Hash
|
||||
existing = subs_col.find_one({
|
||||
'Username': username,
|
||||
'SubscriptionHash': sub_hash
|
||||
})
|
||||
|
||||
if existing:
|
||||
# Update last used time
|
||||
subs_col.update_one(
|
||||
{'_id': existing['_id']},
|
||||
{'$set': {
|
||||
@@ -136,15 +140,19 @@ def save_push_subscription(username, subscription_obj):
|
||||
'IsActive': True
|
||||
}}
|
||||
)
|
||||
logger.info(f'Updated existing subscription for {username}')
|
||||
logger.info('Updated existing push subscription')
|
||||
client.close()
|
||||
return True
|
||||
|
||||
# Save new subscription
|
||||
# Format keys as JSON string for your encrypt_text module
|
||||
keys_str = json.dumps(subscription_obj.get('keys', {}))
|
||||
|
||||
# Save new subscription, encrypting sensitive fields
|
||||
subscription_doc = {
|
||||
'Username': username,
|
||||
'Endpoint': subscription_obj['endpoint'],
|
||||
'Keys': subscription_obj.get('keys', {}),
|
||||
'UsernameHash': _get_username_hash(username),
|
||||
'Username': encrypt_text(username),
|
||||
'Endpoint': encrypt_text(endpoint),
|
||||
'Keys': encrypt_text(keys_str),
|
||||
'SubscriptionHash': sub_hash,
|
||||
'IsActive': True,
|
||||
'CreatedAt': datetime.datetime.now(),
|
||||
@@ -153,36 +161,31 @@ def save_push_subscription(username, subscription_obj):
|
||||
}
|
||||
|
||||
subs_col.insert_one(subscription_doc)
|
||||
logger.info(f'Saved new push subscription for {username}')
|
||||
logger.info('Saved new encrypted push subscription')
|
||||
client.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving push subscription for {username}: {e}')
|
||||
logger.error(f'Error saving push subscription: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def remove_push_subscription(username, endpoint):
|
||||
"""
|
||||
Remove a push subscription
|
||||
|
||||
Args:
|
||||
username (str): Username
|
||||
endpoint (str): Subscription endpoint URL
|
||||
|
||||
Returns:
|
||||
bool: Success status
|
||||
Remove a push subscription by making it inactive.
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
# Recreate the deterministic hash to find the specific subscription
|
||||
sub_hash = hashlib.shake_256(
|
||||
f"{username}:{endpoint}".encode('utf-8')
|
||||
).hexdigest()
|
||||
|
||||
result = subs_col.update_one(
|
||||
{
|
||||
'Username': username,
|
||||
'Endpoint': endpoint
|
||||
},
|
||||
{'SubscriptionHash': sub_hash},
|
||||
{'$set': {'IsActive': False}}
|
||||
)
|
||||
|
||||
@@ -190,31 +193,19 @@ def remove_push_subscription(username, endpoint):
|
||||
return result.modified_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error removing push subscription for {username}: {e}')
|
||||
logger.error(f'Error removing push subscription: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
|
||||
"""
|
||||
Send a push notification to all user's subscriptions
|
||||
|
||||
Args:
|
||||
username (str): Target username
|
||||
title (str): Notification title
|
||||
body (str): Notification body
|
||||
icon (str, optional): Icon URL
|
||||
url (str, optional): URL to open on click
|
||||
reference (dict, optional): Reference data (item_id, etc)
|
||||
tag (str, optional): Notification tag for grouping
|
||||
|
||||
Returns:
|
||||
int: Number of successfully sent notifications
|
||||
Send a push notification to all user's subscriptions.
|
||||
"""
|
||||
try:
|
||||
subscriptions = get_user_subscriptions(username)
|
||||
|
||||
if not subscriptions:
|
||||
logger.debug(f'No active push subscriptions for {username}')
|
||||
logger.debug('No active push subscriptions for user')
|
||||
return 0
|
||||
|
||||
sent_count = 0
|
||||
@@ -232,19 +223,18 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
|
||||
if success:
|
||||
sent_count += 1
|
||||
else:
|
||||
# Mark subscription as inactive if send fails
|
||||
_mark_subscription_inactive(subscription['_id'])
|
||||
|
||||
logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions')
|
||||
logger.info(f'Sent push notification: {sent_count}/{len(subscriptions)} subscriptions')
|
||||
return sent_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending push notification to {username}: {e}')
|
||||
logger.error(f'Error sending push notification: {e}')
|
||||
return 0
|
||||
|
||||
|
||||
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
||||
"""Send push notification to a specific subscription"""
|
||||
"""Send push notification to a specific decrypted subscription"""
|
||||
try:
|
||||
payload = {
|
||||
'title': title,
|
||||
@@ -256,20 +246,17 @@ def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
||||
'reference': reference or {},
|
||||
}
|
||||
|
||||
# If using Firebase Cloud Messaging
|
||||
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
|
||||
return _send_fcm_notification(subscription, payload)
|
||||
|
||||
# Otherwise use standard Web Push Protocol
|
||||
return _send_web_push_notification(subscription, payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}')
|
||||
logger.error(f'Error sending to subscription: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def _send_fcm_notification(subscription, payload):
|
||||
"""Send notification via Firebase Cloud Messaging"""
|
||||
try:
|
||||
if not FCM_API_KEY:
|
||||
logger.warning('FCM_API_KEY not configured')
|
||||
@@ -311,9 +298,7 @@ def _send_fcm_notification(subscription, payload):
|
||||
|
||||
|
||||
def _send_web_push_notification(subscription, payload):
|
||||
"""Send notification using standard Web Push Protocol"""
|
||||
try:
|
||||
# This requires pywebpush library
|
||||
from pywebpush import webpush
|
||||
|
||||
webpush(
|
||||
@@ -325,13 +310,13 @@ def _send_web_push_notification(subscription, payload):
|
||||
vapid_private_key=VAPID_PRIVATE_KEY,
|
||||
vapid_claims={'sub': VAPID_SUBJECT},
|
||||
timeout=10,
|
||||
ttl=3600 # Notification expires after 1 hour if device is offline
|
||||
ttl=3600
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.warning('pywebpush not installed, install with: pip install pywebpush')
|
||||
logger.warning('pywebpush not installed. pip install pywebpush')
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f'Web push error: {e}')
|
||||
@@ -339,7 +324,6 @@ def _send_web_push_notification(subscription, payload):
|
||||
|
||||
|
||||
def _mark_subscription_inactive(subscription_id):
|
||||
"""Mark a subscription as inactive (e.g., after failed send)"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
@@ -349,37 +333,21 @@ def _mark_subscription_inactive(subscription_id):
|
||||
{'_id': ObjectId(subscription_id)},
|
||||
{'$set': {'IsActive': False}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Error marking subscription inactive: {e}')
|
||||
|
||||
|
||||
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
||||
"""
|
||||
Send a push notification to all admin users
|
||||
|
||||
Args:
|
||||
title (str): Notification title
|
||||
body (str): Notification body
|
||||
icon (str, optional): Icon URL
|
||||
url (str, optional): URL to open on click
|
||||
reference (dict, optional): Reference data
|
||||
|
||||
Returns:
|
||||
int: Total notifications sent
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
users_col = db['users']
|
||||
|
||||
# Get all admin users
|
||||
admin_users = list(users_col.find(
|
||||
{'Admin': True},
|
||||
{'Username': 1}
|
||||
))
|
||||
|
||||
client.close()
|
||||
|
||||
total_sent = 0
|
||||
@@ -404,10 +372,6 @@ def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
||||
|
||||
|
||||
def cleanup_inactive_subscriptions():
|
||||
"""
|
||||
Remove inactive subscriptions older than 30 days
|
||||
Run this periodically as a maintenance task
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
@@ -429,22 +393,19 @@ def cleanup_inactive_subscriptions():
|
||||
return 0
|
||||
|
||||
|
||||
# Database collection schema
|
||||
def ensure_push_subscriptions_collection():
|
||||
"""Ensure the push_subscriptions collection exists with proper indexes"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
# Create indexes
|
||||
subs_col.create_index('Username')
|
||||
subs_col.create_index([('Username', 1), ('IsActive', 1)])
|
||||
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
|
||||
subs_col.create_index('UsernameHash')
|
||||
subs_col.create_index([('UsernameHash', 1), ('IsActive', 1)])
|
||||
subs_col.create_index([('CreatedAt', 1)])
|
||||
subs_col.create_index('SubscriptionHash', unique=True)
|
||||
|
||||
logger.info('Push subscriptions collection indexes created')
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
||||
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
||||
@@ -1144,6 +1144,17 @@
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
|
||||
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
|
||||
{% if current_permissions.pages.get('home', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
|
||||
{% endif %}
|
||||
@@ -1155,6 +1166,42 @@
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<div class="function-search-wrap">
|
||||
<form class="function-search-form" data-function-search="true">
|
||||
<input
|
||||
class="function-search-input"
|
||||
type="search"
|
||||
name="function_search"
|
||||
placeholder="Funktion suchen..."
|
||||
list="function-search-options"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button class="function-search-btn" type="submit">Los</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if current_tenant_db %}
|
||||
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
|
||||
{% endif %}
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
|
||||
{% if current_permissions.pages.get('notifications_view', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -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()">×</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,21 +1097,48 @@
|
||||
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
|
||||
// =========================================================================
|
||||
function wireScannerUi() {
|
||||
@@ -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()">×</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;">×</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 %}
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user