Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 9da4ff3ec8 | |||
| 884464cfe5 | |||
| 0be4e3ca58 | |||
| e2aeea46f9 | |||
| 865fddd45b |
@@ -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.
|
||||
|
||||
@@ -265,7 +265,9 @@ def remove_done():
|
||||
|
||||
|
||||
def get_upcoming_for_user(user: str, limit: int = 25):
|
||||
"""Return upcoming appointment plans for a user, matching by encrypted username."""
|
||||
"""
|
||||
Return upcoming appointment plans for a user, handling encrypted database records.
|
||||
"""
|
||||
try:
|
||||
if hasattr(globals(), 'remove_done'):
|
||||
remove_done()
|
||||
@@ -279,22 +281,26 @@ def get_upcoming_for_user(user: str, limit: int = 25):
|
||||
items = db['appointments']
|
||||
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
|
||||
encrypted_user = dp.encrypt_text(str(user or '').strip())
|
||||
target_user = str(user or '').strip()
|
||||
|
||||
cursor = items.find(
|
||||
_active_record_query(
|
||||
{
|
||||
'user': encrypted_user,
|
||||
'date_end': {'$gte': today},
|
||||
}
|
||||
)
|
||||
_active_record_query({
|
||||
'date_end': {'$gte': today},
|
||||
})
|
||||
).sort('date_start', 1)
|
||||
|
||||
results = []
|
||||
for item in cursor:
|
||||
item['_id'] = str(item.get('_id'))
|
||||
results.append(_decrypt_appointment(item))
|
||||
decrypted_item = _decrypt_appointment(item)
|
||||
if not decrypted_item:
|
||||
continue
|
||||
|
||||
if decrypted_item.get('user', '').strip() != target_user:
|
||||
continue
|
||||
|
||||
decrypted_item['_id'] = str(decrypted_item.get('_id'))
|
||||
results.append(decrypted_item)
|
||||
|
||||
if len(results) >= max(1, int(limit)):
|
||||
break
|
||||
|
||||
@@ -304,4 +310,5 @@ def get_upcoming_for_user(user: str, limit: int = 25):
|
||||
return []
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
client.close()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
|
||||
from flask import Response
|
||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash, make_response, Response, send_file
|
||||
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
|
||||
from flask import make_response, flash, redirect, url_for, session
|
||||
import qrcode
|
||||
import os
|
||||
import tempfile
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib.colors import grey, HexColor
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
|
||||
|
||||
|
||||
# Create a blueprint instance
|
||||
appoint_bp = Blueprint('terminplaner', __name__)
|
||||
|
||||
|
||||
def _get_school_info_for_export():
|
||||
"""
|
||||
Get school information for PDF exports from configuration or database.
|
||||
Returns default info if not configured.
|
||||
"""
|
||||
try:
|
||||
if hasattr(cfg, 'get_school_info'):
|
||||
return cfg.get_school_info()
|
||||
|
||||
school_info = {
|
||||
'name': 'Schulname',
|
||||
'address': 'Schuladresse',
|
||||
'postal_code': 'PLZ',
|
||||
'city': 'Stadt',
|
||||
'school_number': '000000',
|
||||
'it_admin': 'IT-Beauftragter/in',
|
||||
'logo_path': '',
|
||||
}
|
||||
return school_info
|
||||
except Exception:
|
||||
# Return defaults if anything fails
|
||||
return {
|
||||
'name': 'Schulname',
|
||||
'address': 'Schuladresse',
|
||||
'postal_code': 'PLZ',
|
||||
'city': 'Stadt',
|
||||
'school_number': '000000',
|
||||
'it_admin': 'IT-Beauftragter/in',
|
||||
'logo_path': '',
|
||||
}
|
||||
|
||||
|
||||
def _require_module_enabled():
|
||||
if not cfg.MODULES.is_enabled('terminplan'):
|
||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||
@@ -175,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):
|
||||
@@ -199,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')
|
||||
)
|
||||
|
||||
|
||||
@@ -316,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(
|
||||
@@ -326,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')
|
||||
)
|
||||
|
||||
|
||||
@@ -364,6 +408,138 @@ def client_slot_calendar_export(appointment_id):
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||
return response
|
||||
|
||||
|
||||
@appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET'])
|
||||
def export_pdf_brief(plan_id):
|
||||
# 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an)
|
||||
# terminplan = Terminplan.query.get(plan_id)
|
||||
|
||||
school_info = _get_school_info_for_export()
|
||||
|
||||
school_name = school_info.get('name', 'Schulname')
|
||||
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}",
|
||||
"schulnummer": school_number,
|
||||
"it_admin": it_admin
|
||||
}
|
||||
|
||||
plan_daten = {
|
||||
"titel": termin.get_item(plan_id).get('title', 'Terminplan'), # terminplan.title
|
||||
"link": link, # terminplan.link
|
||||
"notizen": termin.get_item(plan_id).get('note', '') # terminplan.note
|
||||
}
|
||||
|
||||
# 2. QR-Code Bild im temporären Ordner erstellen
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=0)
|
||||
qr.add_data(plan_daten["link"])
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
fd, qr_path = tempfile.mkstemp(suffix=".png")
|
||||
os.close(fd)
|
||||
img.save(qr_path)
|
||||
|
||||
# 3. PDF im Speicher aufbauen (BytesIO)
|
||||
pdf_buffer = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
pdf_buffer,
|
||||
pagesize=A4,
|
||||
rightMargin=2*cm,
|
||||
leftMargin=2.5*cm,
|
||||
topMargin=2.5*cm,
|
||||
bottomMargin=2*cm
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(name='Sender', fontSize=8, textColor=grey))
|
||||
styles.add(ParagraphStyle(name='Address', fontSize=10, leading=14))
|
||||
styles.add(ParagraphStyle(name='Date', fontSize=10, alignment=2))
|
||||
styles.add(ParagraphStyle(name='Subject', fontSize=14, fontName='Helvetica-Bold', spaceAfter=16, textColor=HexColor('#0f4c5c')))
|
||||
styles.add(ParagraphStyle(name='Body', fontSize=11, leading=16, spaceAfter=12))
|
||||
styles.add(ParagraphStyle(name='Notes', fontSize=10, leading=14, textColor=HexColor("#444444")))
|
||||
|
||||
elements = []
|
||||
|
||||
# Absenderzeile
|
||||
sender_text = f"<u>{schul_daten['schulname']} • {schul_daten['strasse']} • {schul_daten['plz_ort']}</u>"
|
||||
elements.append(Paragraph(sender_text, styles['Sender']))
|
||||
elements.append(Spacer(1, 1.5*cm))
|
||||
|
||||
# Sichtfenster-Adresse (Generisch)
|
||||
elements.append(Paragraph("An die<br/>Teilnehmerinnen und Teilnehmer<br/>des Termins", styles['Address']))
|
||||
elements.append(Spacer(1, 2*cm))
|
||||
|
||||
# Datum (Hier statisch zum Test, ggf. dynamisch per datetime)
|
||||
import datetime
|
||||
heute = datetime.datetime.now().strftime("%d.%m.%Y")
|
||||
elements.append(Paragraph(f"{schul_daten['plz_ort']}, den {heute}", styles['Date']))
|
||||
elements.append(Spacer(1, 1*cm))
|
||||
|
||||
# Betreff
|
||||
elements.append(Paragraph(f"Einladung zur Terminbuchung: {plan_daten['titel']}", styles['Subject']))
|
||||
|
||||
# Text
|
||||
elements.append(Paragraph("Sehr geehrte Damen und Herren,", styles['Body']))
|
||||
elements.append(Paragraph("hiermit möchten wir Sie herzlich einladen, einen Termin für unsere anstehende Veranstaltung zu buchen. Um den Prozess für alle Beteiligten so einfach und effizient wie möglich zu gestalten, nutzen wir unser Online-Buchungssystem.", styles['Body']))
|
||||
elements.append(Spacer(1, 0.5*cm))
|
||||
|
||||
# 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>'
|
||||
elements.append(Paragraph(link_html, styles['Body']))
|
||||
|
||||
# Das vorhin erstellte QR-Code Bild einfügen
|
||||
elements.append(Spacer(1, 0.2*cm))
|
||||
elements.append(Image(qr_path, width=3*cm, height=3*cm, hAlign='LEFT'))
|
||||
elements.append(Spacer(1, 0.5*cm))
|
||||
|
||||
# Notizen (falls vorhanden)
|
||||
if plan_daten.get('notizen'):
|
||||
elements.append(Paragraph("<b>Zusätzliche Informationen zum Termin:</b>", styles['Body']))
|
||||
elements.append(Paragraph(plan_daten['notizen'], styles['Notes']))
|
||||
|
||||
elements.append(Spacer(1, 1.5*cm))
|
||||
|
||||
# Grußformel
|
||||
elements.append(Paragraph("Mit freundlichen Grüßen,", styles['Body']))
|
||||
elements.append(Spacer(1, 1.5*cm))
|
||||
elements.append(Paragraph(f"<b>{schul_daten['schulname']}</b>", styles['Body']))
|
||||
|
||||
# PDF fertigstellen
|
||||
doc.build(elements)
|
||||
|
||||
# Temporäres Bild löschen
|
||||
if os.path.exists(qr_path):
|
||||
os.remove(qr_path)
|
||||
|
||||
# Buffer auf Anfang zurücksetzen
|
||||
pdf_buffer.seek(0)
|
||||
|
||||
# An Nutzer ausliefern
|
||||
return send_file(
|
||||
pdf_buffer,
|
||||
mimetype='application/pdf',
|
||||
as_attachment=True,
|
||||
download_name=f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf"
|
||||
)
|
||||
|
||||
@appoint_bp.route('/')
|
||||
def main():
|
||||
guard = _require_module_enabled()
|
||||
@@ -374,7 +550,6 @@ def main():
|
||||
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
flash(f'{upcoming_events[0].title}' if upcoming_events else 'No upcoming events', 'info')
|
||||
|
||||
return render_template(
|
||||
'terminplaner.html',
|
||||
@@ -382,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 %}
|
||||
@@ -155,7 +155,7 @@
|
||||
const slotLengthInput = document.getElementById('slot_length');
|
||||
const clientsperslot = document.getElementById('clients_per_slot')
|
||||
const slotsAmountsInput = document.getElementById('slots_amounts');
|
||||
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
|
||||
const slotsAmountsDisplay = document.getElementById('slots_amounts_display');
|
||||
|
||||
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
||||
return;
|
||||
@@ -442,6 +442,19 @@
|
||||
if (startDateInput.value && endDateInput.value) {
|
||||
renderRows();
|
||||
}
|
||||
|
||||
|
||||
const configForm = document.querySelector('form');
|
||||
if (configForm) {
|
||||
configForm.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.target.tagName === 'TEXTAREA') return;
|
||||
if (event.target.tagName === 'BUTTON' && event.target.type === 'submit') return;
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -6,6 +6,8 @@
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-11 col-xl-10">
|
||||
|
||||
<!-- Hero Sektion -->
|
||||
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
||||
<div>
|
||||
@@ -20,6 +22,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- NEU EINGEBAUT: Erfolgsmeldung für gerade erstellte Buchungslinks -->
|
||||
{% if generated_link %}
|
||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||
<div class="mb-2">
|
||||
{% if mail_service_enabled %}
|
||||
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||
{% else %}
|
||||
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ generated_link }}" class="d-inline-block text-break mb-3">{{ generated_link }}</a>
|
||||
|
||||
<!-- PDF Export per ReportLab -->
|
||||
<div class="pt-3 border-top border-success-subtle">
|
||||
<div class="fw-semibold mb-1">Einladungsbrief (inkl. QR-Code)</div>
|
||||
<p class="small text-muted mb-2">Laden Sie einen automatisch generierten Brief herunter, der den Buchungslink und einen passenden QR-Code enthält.</p>
|
||||
<a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm">
|
||||
📄 Brief als PDF herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if calendar_link %}
|
||||
<div class="mt-3 pt-3 border-top border-success-subtle">
|
||||
<div class="fw-semibold mb-1">Kalendereintrag</div>
|
||||
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- ENDE NEUER BLOCK -->
|
||||
|
||||
<!-- Info-Karten -->
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
@@ -59,6 +94,7 @@
|
||||
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
||||
</div>
|
||||
|
||||
<!-- "Tabelle" (Liste) der kommenden Termine -->
|
||||
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
|
||||
@@ -84,10 +120,18 @@
|
||||
<div class="text-lg-end">
|
||||
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
|
||||
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</a>
|
||||
|
||||
<!-- Hinzugefügt: Direkter PDF-Export auch bei bestehenden Plänen -->
|
||||
<a class="btn btn-sm btn-outline-success" href="{{ url_for('terminplaner.export_pdf_brief', plan_id=event.appointment_id) }}" title="Brief herunterladen">
|
||||
📄 PDF
|
||||
</a>
|
||||
|
||||
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link</a>
|
||||
|
||||
{% if event.calendar_link %}
|
||||
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
|
||||
</form>
|
||||
@@ -105,4 +149,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -100,17 +100,25 @@ docker_backup() {
|
||||
output_dir="/tmp/invoice_archive_${timestamp}"
|
||||
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 \
|
||||
# Wir verketten die kritischen Schritte mit &&.
|
||||
# Falls der Pfad doch '/app/Web/...' sein sollte, passe ihn hier einfach wieder an.
|
||||
docker compose -f "$COMPOSE_FILE" exec -T app mkdir -p "$output_dir" && \
|
||||
docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/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/"
|
||||
--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/"
|
||||
|
||||
# Hier fangen wir den Exit-Code der gesamten Kette ab
|
||||
local backup_status=$?
|
||||
|
||||
# Dieser Befehl läuft immer (Aufräumen im Container), verändert aber nicht unseren Rückgabetatbestand
|
||||
docker compose -f "$COMPOSE_FILE" exec -T app rm -rf "$output_dir"
|
||||
|
||||
# Jetzt geben wir den echten Status der Backup-Kette zurück
|
||||
return $backup_status
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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