Compare commits

...

73 Commits

Author SHA1 Message Date
Aiirondev_dev 20528b60c5 changes 2026-07-16 14:53:27 +02:00
Aiirondev_dev c610c06a0e changes for the backup process 2026-07-16 14:40:06 +02:00
Aiirondev_dev 2cef1672d2 changes to the backup 2026-07-16 14:12:54 +02:00
Aiirondev_dev eb8542c410 changes to the backup.sh 2026-07-16 13:49:19 +02:00
Aiirondev_dev 1ebd83ec2c changes to the backup.sh 2026-07-16 13:41:39 +02:00
Aiirondev_dev e80bf946c0 changes to the ussage of backup.sh 2026-07-16 13:39:21 +02:00
Aiirondev_dev bfbbff597e final changes to account for the switch from github to gitea 2026-07-16 12:08:09 +02:00
Aiirondev_dev d80f3f56b7 changes to the release action 2026-07-16 11:16:54 +02:00
Aiirondev_dev 726fca0866 fix of wrong Registry keys
Release Inventarsystem / release-docker (push) Failing after 2m40s
2026-07-16 10:26:28 +02:00
Aiirondev_dev 614f73250a changes to the release 2026-07-16 10:10:56 +02:00
Aiirondev_dev 0d2488eb0b release changes 2026-07-16 08:23:10 +02:00
Aiirondev_dev 7e6a1d6b03 fiy of a typo 2026-07-16 08:15:15 +02:00
Aiirondev_dev ebf144f1a9 changes to the remote execution of the release 2026-07-16 08:09:15 +02:00
Aiirondev_dev 78fda8d569 Test deployment 2026-07-14 01:02:56 +02:00
Aiirondev_dev 248594ce0d adjustment for the runner 2026-07-14 00:59:38 +02:00
Aiirondev_dev 783c68a243 Try difrent adjustments 2026-07-14 00:53:19 +02:00
Aiirondev_dev 031d41643e adjustment for a problem with the ubuntu-latest 2026-07-14 00:51:31 +02:00
Aiirondev_dev cae3199e53 Adjustments to the release-docker.yml 2026-07-14 00:48:03 +02:00
Aiirondev_dev d0f2afae57 slight changes to the dispaying of the Detailed Library Modals 2026-07-07 22:25:55 +02:00
Aiirondev_dev 1a99448a2e slight css fix for the accesability of the script 2026-07-07 21:43:44 +02:00
Aiirondev_dev aec25a13b9 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-07-05 13:40:59 +02:00
Aiirondev_dev b31abe2177 Adjustments to the intput field for the ÖLibrary funktion 2026-07-05 13:40:48 +02:00
Aiirondev_dev 7fd27e8c02 additional changes to account for legacy Item Types 2026-07-05 12:02:57 +02:00
Aiirondev_dev 5f5784e7c3 addition of a debuging step 2026-07-05 11:52:57 +02:00
Aiirondev_dev 613c1e11e4 slight changes of important kexy filters for the retrival of Library Items 2026-07-05 11:46:30 +02:00
Aiirondev_dev 9e3b54c1b1 slight changes to the updating process to restart after such an event 2026-07-05 11:40:06 +02:00
Aiirondev_dev a8bce26eab Adjust for a normal scanner to be able t scann as well 2026-06-30 12:04:35 +02:00
Aiirondev_dev 36c391e99c fix in the export of excel lists 2026-06-30 11:32:17 +02:00
Aiirondev_dev 40c3970719 better excel export 2026-06-30 11:22:18 +02:00
Aiirondev_dev e7f4b1ce9d Slkight fix for the Items types 2026-06-30 10:43:09 +02:00
Aiirondev_dev d84466a3cc changes to the Library Item Type to reflekt the correct types 2026-06-30 10:04:59 +02:00
Aiirondev_dev 0efc01a835 slight adjustments 2026-06-28 23:47:58 +02:00
Aiirondev_dev 73cf63a56d changes 2026-06-28 23:34:42 +02:00
Aiirondev_dev fdcd26326b slight changes to correctly reflect all relevant things 2026-06-28 23:28:42 +02:00
Aiirondev_dev 1fd9e230ac changes of the upddating of library Items 2026-06-28 23:13:36 +02:00
Aiirondev_dev cc0c4e28b7 Development 2026-06-28 18:28:29 +02:00
Aiirondev_dev ea73b98549 Adjustments to the edeting funktion fpo 2026-06-28 17:03:10 +02:00
Aiirondev_dev d6dda0f25a Slight adjustments 2026-06-28 16:55:57 +02:00
Aiirondev_dev 46176c1741 slight fixes 2026-06-28 16:45:53 +02:00
Aiirondev_dev c57b4a9a44 edit Modal changes to accomidate the library Modul 2026-06-28 16:30:41 +02:00
Aiirondev_dev 6f424cc8aa Backup and restore funktionality 2026-06-28 15:55:49 +02:00
Aiirondev_dev 103b82af15 smal fix 2026-06-28 00:11:16 +02:00
Aiirondev_dev 69c8aa8700 Development fix 2026-06-27 23:59:56 +02:00
Aiirondev_dev 1fa3dace17 changes for a smal issue 2026-06-27 23:51:47 +02:00
Aiirondev_dev 6dced8b1a5 additional fix for the cathergorie 2026-06-27 23:33:01 +02:00
Aiirondev_dev 493ee2ea7f fiy of a funktion issue 2026-06-27 23:28:00 +02:00
Aiirondev_dev 52a2ec0cad Adding of the detailed module 2026-06-27 23:10:14 +02:00
Aiirondev_dev b5f2c3c5c5 Adding of the category 2026-06-27 23:09:24 +02:00
Aiirondev_dev 403c281c93 Debug? 2026-06-27 22:52:40 +02:00
Aiirondev_dev 6d121f6110 Slight adjustment 2026-06-27 22:37:04 +02:00
Aiirondev_dev 9d940c6151 Denbugging and fix of a Code_4 generation issue 2026-06-27 22:20:10 +02:00
Aiirondev_dev 980b825e07 Test 2026-06-27 22:11:28 +02:00
Aiirondev_dev 94326728eb development debuggig 2026-06-27 22:01:51 +02:00
Aiirondev_dev efe17883a6 Development version 2026-06-27 21:51:50 +02:00
Aiirondev_dev 76f93b3d2e Fix of an unused detail in the Detail Modal 2026-06-27 21:18:51 +02:00
Aiirondev_dev 8f793045c2 removed unused return information 2026-06-27 17:24:42 +02:00
Aiirondev_dev a199439d76 slight fix 2026-06-27 17:20:46 +02:00
Aiirondev_dev 6cec483390 slight change to the mongodb accesing 2026-06-27 17:08:59 +02:00
Aiirondev_dev 10e2f7cc17 fur5ther fixes of to much information getting out 2026-06-27 17:00:40 +02:00
Aiirondev_dev 0f8c5a0fec slight adjustments for the security 2026-06-27 16:29:13 +02:00
Aiirondev_dev 091147ecfd slight security fixes to prevent malisios actors from getting access or retrieving unfit information 2026-06-27 16:07:26 +02:00
Aiirondev_dev 7bedfc1558 implementet encryption and decryption to the audit to provide a complete lak of context for a petential attacker 2026-06-27 13:45:21 +02:00
Aiirondev_dev 7e258e07ab some more savety processing 2026-06-27 13:28:49 +02:00
Aiirondev_dev 96245bb06b Fix of an legacy encryption Algorithm 2026-06-27 12:49:56 +02:00
Aiirondev_dev 245c3c19dd Fix of the SSRF Protection 2026-06-27 12:43:19 +02:00
Aiirondev_dev d76c69d836 SSRF Protection Implementation 2026-06-27 12:39:25 +02:00
Aiirondev_dev 9527fc10cf Fix of the Nav Bar for the Terminplaner Module 2026-06-27 11:31:47 +02:00
Aiirondev_dev 1122618a51 slight mispelled variable name 2026-06-27 11:05:35 +02:00
Aiirondev_dev 54d56fc463 link klorrektion 2026-06-27 10:53:28 +02:00
Aiirondev_dev 9da4ff3ec8 circuilar import fix 2026-06-27 10:08:12 +02:00
Aiirondev_dev 884464cfe5 pdf download for a client Brief 2026-06-27 10:00:10 +02:00
Aiirondev_dev 0be4e3ca58 return is beeing ignored to the submition 2026-06-27 00:35:44 +02:00
Aiirondev_dev e2aeea46f9 Fix of the encryption of when getting the upcoming events for a user 2026-06-27 00:16:59 +02:00
20 changed files with 1316 additions and 528 deletions
+62 -33
View File
@@ -1,4 +1,4 @@
name: https://github.com/AIIrondev/legendary-octo-garbanzo
name: Release Inventarsystem
on:
push:
@@ -25,6 +25,7 @@ env:
jobs:
release-docker:
# Hinweis: Falls dein lokaler Gitea-Runner ein anderes Label hat (z.B. 'linux' oder 'self-hosted'), passe dies hier an.
runs-on: ubuntu-latest
steps:
@@ -34,18 +35,20 @@ jobs:
- name: Set metadata
id: meta
env:
# Gitea stellt das GITHUB_TOKEN für Kompatibilität mit GitHub Actions automatisch zur Verfügung
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
REPO: ${{ gitea.repository }}
EVENT_NAME: ${{ gitea.event_name }}
REF_NAME: ${{ gitea.ref_name }}
BUMP_TYPE: ${{ gitea.event.inputs.bump || 'patch' }}
DOCKER_API_VERSION: '1.44'
run: |
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
TAG="$REF_NAME"
else
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
# Gitea API Endpunkt nutzen, um das aktuellste Release abzufragen
latest_tag="v0.8.31"
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases/latest" 2>/dev/null); then
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
if [ -n "$tag_name" ]; then
latest_tag="$tag_name"
@@ -108,19 +111,58 @@ jobs:
TAG="${base}.${i}"
fi
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
# Docker Images verlangen Kleinbuchstaben. Repository-Namen daher umwandeln.
LOWER_REPO=$(echo "$REPO" | tr '[:upper:]' '[:lower:]')
IMAGE="git.invario-software.eu/${LOWER_REPO}:${TAG}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT"
- name: Ensure Docker CLI is available and up to date
run: |
install_docker=true
# Prüfen, ob Docker existiert und ob die Version ausreicht
if command -v docker >/dev/null 2>&1; then
# Extrahiere die Major-Version (z. B. "20" aus "20.10.24" oder "26" aus "26.1.0")
DOCKER_MAJOR=$(docker --version | grep -oE '[0-9]+' | head -n1)
# API 1.44 erfordert mindestens Docker v25
if [ -n "$DOCKER_MAJOR" ] && [ "$DOCKER_MAJOR" -ge 25 ]; then
install_docker=false
fi
fi
if [ "$install_docker" = true ]; then
echo "Veraltete oder fehlende Docker-Installation erkannt. Führe Update durch..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y curl
# Nutzt das offizielle Docker-Skript (installiert docker-ce statt das alte docker.io)
curl -fsSL https://get.docker.com | sh
elif command -v apk >/dev/null 2>&1; then
apk update
apk add --no-cache docker-cli
else
echo "Error: no supported package manager found to install docker"
exit 1
fi
else
echo "Docker CLI ist bereits auf einem aktuellen Stand."
fi
docker --version
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: git.invario-software.eu
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push release image
uses: docker/build-push-action@v6
@@ -128,10 +170,9 @@ jobs:
context: .
file: ./Dockerfile
push: true
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
tags: |
${{ steps.meta.outputs.image }}
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
- name: Build local image tar for offline deploy
run: |
@@ -139,20 +180,10 @@ jobs:
IMG="${{ steps.meta.outputs.image }}"
TAG="${{ steps.meta.outputs.tag }}"
if docker image inspect "$IMG" >/dev/null 2>&1; then
echo "Using local image $IMG"
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
exit 0
fi
echo "Pulling freshly pushed image from registry: $IMG"
docker pull "$IMG"
echo "Local image $IMG not found, trying to pull"
if docker pull "$IMG" >/dev/null 2>&1; then
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
exit 0
fi
echo "Pull failed, attempting local docker build as fallback"
docker build -t "$IMG" .
echo "Saving image to offline tar archive..."
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
- name: Create release-only docker bundle
@@ -161,7 +192,7 @@ jobs:
cat > release-bundle/docker-compose.yml <<EOF
services:
app:
image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
image: \${INVENTAR_APP_IMAGE:-${{ steps.meta.outputs.image }}}
container_name: inventarsystem-app
restart: unless-stopped
ports:
@@ -242,12 +273,10 @@ jobs:
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release
uses: softprops/action-gh-release@v2
- name: Create or update Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.meta.outputs.tag }}
files: |
inventarsystem-docker-bundle.tar.gz
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
fail_on_unmatched_files: false
generate_release_notes: true
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
+3 -3
View File
@@ -120,19 +120,19 @@ Das Inventarsystem stellt folgende Wartungsskripte bereit:
**Option 1**
```bash
wget -O - https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | sudo bash
wget -O - https://git.invario-software.eu/Invario/Inventarsystem/raw/branch/main/install.sh | sudo bash
```
**Option 2**
```bash
curl -s https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | sudo bash
curl -s https://git.invario-software.eu/Invario/Inventarsystem/raw/branch/main/install.sh | sudo bash
```
Legacy-MongoDB uebernehmen und altes Host-System aufraeumen:
```bash
curl -s https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | \
curl -s https://git.invario-software.eu/Invario/Inventarsystem/raw/branch/main/install.sh | \
sudo bash -s -- --migrate-legacy-db --remove-legacy-system
```
+302 -213
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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.'
}
+37 -40
View File
@@ -24,7 +24,7 @@ import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
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.
+19 -10
View File
@@ -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,20 +281,26 @@ def get_upcoming_for_user(user: str, limit: int = 25):
items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d')
target_user = str(user or '').strip()
cursor = items.find(
_active_record_query(
{
'user': 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(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
@@ -302,4 +310,5 @@ def get_upcoming_for_user(user: str, limit: int = 25):
return []
finally:
if client:
client.close()
client.close()
+15 -12
View File
@@ -37,20 +37,23 @@ def generate_library_excel(items):
item.get("ReturnDate", ""),
item.get("Anschaffungskosten", "")
]
ws.append(row)
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
ws.column_dimensions[column].width = min(max_length + 2, 50)
# FIX: Clean the row data INSIDE the loop for every single item
clean_row = []
for value in row:
if isinstance(value, list):
# Converts ['A', 'B'] to "A, B" and an empty list [] to an empty string ""
clean_row.append(", ".join(map(str, value)) if value else "")
elif isinstance(value, dict):
# Just in case there is an embedded MongoDB sub-document
clean_row.append(str(value))
else:
clean_row.append(value)
# Append the safe, flattened row to the worksheet
ws.append(clean_row)
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
return excel_buffer
return excel_buffer
+36 -13
View File
@@ -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 = {
+2 -3
View File
@@ -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:
+182 -5
View File
@@ -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()
@@ -381,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
View File
@@ -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}')
+47
View File
@@ -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>
+240 -40
View File
@@ -4,6 +4,57 @@
{% block content %}
<style>
/* The Dark Background Overlay */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.6);
/* Display is managed by JS (none/flex) */
align-items: center;
justify-content: center;
z-index: 1000;
}
/* The White Modal Box */
.modal-content {
background-color: #fff;
padding: 20px;
width: 80%;
max-width: 600px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
/* Flexbox allows the header to stay fixed while the body scrolls */
display: flex;
flex-direction: column;
max-height: 85vh;
}
/* The Close Button */
.close {
align-self: flex-end;
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1;
margin-bottom: 10px;
}
.close:hover,
.close:focus {
color: #000;
text-decoration: none;
}
/* The Scrollable Content Area */
#detailContent {
overflow-y: auto; /* Adds scrollbar only if needed */
padding-right: 10px; /* Prevents text from rubbing against the scrollbar */
}
/* Library table-only view styles */
.library-table-container {
max-width: 1400px;
@@ -475,7 +526,7 @@
<select id="filterType">
<option value="">-- Alle Typen --</option>
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="schoolbook">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</option>
<option value="other">Sonstige</option>
@@ -503,6 +554,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Alle ausgeliehen Excel
</button>
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/all_books'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Alle Bücher Excel
</button>
</div>
</div>
@@ -536,15 +591,14 @@
</div>
</div>
<!-- Detail Modal -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
<div id="detailModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" onclick="closeDetailModal()">&times;</span>
<div id="detailContent"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script>
// =========================================================================
@@ -1033,7 +1087,7 @@
}
function getItemTypeLabel(type) {
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' };
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige', 'schoolbook': 'Schulbuch' };
return labels[type] || type;
}
@@ -1043,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()">&times;</span>
<div class="modal-content" style="max-width: 760px; padding: 25px; border-radius: 8px;">
<span class="close" onclick="closeEditLibraryModal()" style="cursor: pointer; float: right; font-size: 24px;">&times;</span>
<h3 style="margin-top:0;">Bibliotheksmedium bearbeiten</h3>
<!-- Bereich für Gruppen-Informationen (Hier konsolidiert!) -->
<div id="editLibraryGroupWarning" style="display:none; background-color: #fff; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #0ea5e9;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
</div>
<p style="margin: 5px 0; font-size: 12px; color: #555;">
Alle aufgeführten Codes gehören zu diesem Datensatz:
</p>
<!-- Hier werden die Codes per JS eingefügt -->
<div id="editLibraryAllCodes" style="display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px;"></div>
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
</div>
</div>
<form id="editLibraryForm">
<input type="hidden" id="editLibraryItemId">
<div class="edit-grid">
<div class="full">
<label for="editLibraryName">Titel</label>
<input id="editLibraryName" required>
<input id="editLibraryName" required style="width: 100%;">
</div>
<div>
<label for="editLibraryType">Medientyp</label>
<select id="editLibraryType">
<select id="editLibraryType" style="width: 100%;">
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="schoolbook">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</option>
<option value="media">Sonstige Medien</option>
<option value="other">Sonstige Medien</option>
</select>
</div>
<div>
<label for="editLibraryIsbn">ISBN</label>
<input id="editLibraryIsbn" placeholder="optional ISBN-10/13">
<input id="editLibraryIsbn" placeholder="optional ISBN-10/13" style="width: 100%;">
</div>
<div>
<label for="editLibraryCode4">Code</label>
<input id="editLibraryCode4" placeholder="optional Mediencode">
<input id="editLibraryCode4" placeholder="optional Mediencode" style="width: 100%;">
</div>
<div class="full">
<label for="editLibraryLocation">Ort</label>
<input id="editLibraryLocation" required>
<input id="editLibraryLocation" required style="width: 100%;">
</div>
<div class="full">
<label for="editLibraryDescription">Beschreibung</label>
<textarea id="editLibraryDescription" rows="4" required></textarea>
<textarea id="editLibraryDescription" rows="4" required style="width: 100%;"></textarea>
</div>
</div>
<div class="edit-actions">
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern</button>
<div class="edit-actions" style="margin-top:20px;">
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern & Synchronisieren</button>
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
</div>
</form>
</div>
</div>
<div id="editLibraryGroupWarning" style="display:none; background-color: #fff; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #0ea5e9;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
</div>
<p style="margin: 5px 0; font-size: 12px; color: #555;">
Alle Codes in dieser Gruppe:
</p>
<!-- Hier wird die Liste als Komma-Text eingefügt -->
<div id="editLibraryAllCodes" style="font-family: monospace; font-size: 14px; font-weight: bold; color: #333; margin-top: 5px;"></div>
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
</div>
</div>
{% endblock %}
+14 -1
View File
@@ -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 %}
+46 -2
View File
@@ -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 %}
+14 -8
View File
@@ -73,7 +73,7 @@ host_backup() {
return 1
fi
python3 "$SCRIPT_DIR/Web/backup_invoices.py" \
python3 "$SCRIPT_DIR/Web/modules/inventarsystem/backup_invoices.py" \
--archive-dir "$INVOICE_ARCHIVE_DIR" \
--base-name "$BASE_NAME"
}
@@ -101,16 +101,22 @@ docker_backup() {
remote_base="${output_dir}/${BASE_NAME}"
docker compose -f "$COMPOSE_FILE" exec -T app mkdir -p "$output_dir"
docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/Web/backup_invoices.py \
docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/Web/modules/inventarsystem/backup_invoices.py \
--archive-dir "$output_dir" \
--base-name "$BASE_NAME"
mkdir -p "$INVOICE_ARCHIVE_DIR"
docker cp "$app_container":"${remote_base}.jsonl" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.csv" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.meta.json" "$INVOICE_ARCHIVE_DIR/"
if docker compose -f "$COMPOSE_FILE" exec -T app test -f "${remote_base}.jsonl"; then
mkdir -p "$INVOICE_ARCHIVE_DIR"
docker cp "$app_container":"${remote_base}.jsonl" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.csv" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.meta.json" "$INVOICE_ARCHIVE_DIR/"
else
echo "Info: Keine Backup-Dateien zum Kopieren gefunden (Datenbank ist leer). Ueberspringe Dateitransfer."
fi
docker compose -f "$COMPOSE_FILE" exec -T app rm -rf "$output_dir"
return 0
}
main() {
@@ -158,4 +164,4 @@ main() {
}
parse_args "$@"
main
main
+15 -14
View File
@@ -8,8 +8,8 @@ else
INSTALLER_DIR="$(pwd)"
fi
PROJECT_DIR="/opt/Inventarsystem"
REPO_SLUG="AIIrondev/legendary-octo-garbanzo"
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
REPO_SLUG="Invario/Inventarsystem"
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
LEGACY_DB_NAME="Inventarsystem"
@@ -39,7 +39,7 @@ need_cmd() {
refresh_start_script_from_main() {
local start_url
start_url="https://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
if curl -fsSL "$start_url" -o "$TMP_DIR/start.sh"; then
sudo install -m 755 "$TMP_DIR/start.sh" "$PROJECT_DIR/start.sh"
@@ -62,7 +62,7 @@ import re
import sys
compose_file, tag = sys.argv[1], sys.argv[2]
target_image = f"ghcr.io/aiirondev/legendary-octo-garbanzo:{tag}"
target_image = f"git.invario-software.eu/invario/inventarsystem:{tag}"
with open(compose_file, "r", encoding="utf-8") as f:
lines = f.readlines()
@@ -173,9 +173,9 @@ Usage: $0 [options]
Options:
--migrate-legacy-db Back up host MongoDB and import into Docker MongoDB
--remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration
@@ -432,8 +432,9 @@ main() {
curl -fL "$image_url" -o "$TMP_DIR/inventarsystem-image-$tag.tar.gz"
sudo docker load -i "$TMP_DIR/inventarsystem-image-$tag.tar.gz" >/dev/null
# Compatibility alias: some legacy compose bundles may still reference :latest.
sudo docker tag "ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" "ghcr.io/aiirondev/legendary-octo-garbanzo:latest" >/dev/null 2>&1 || true
# Tagge das geladene Gitea-Image als latest
sudo docker tag "git.invario-software.eu/invario/inventarsystem:$tag" "git.invario-software.eu/invario/inventarsystem:latest" >/dev/null 2>&1 || true
if [ ! -f "$PROJECT_DIR/start.sh" ]; then
echo "Error: release bundle is missing start.sh"
@@ -470,19 +471,19 @@ EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
INVENTAR_APP_IMAGE=git.invario-software.eu/invario/inventarsystem:$tag
EOF
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
elif sudo grep -q '^INVENTAR_APP_IMAGE=' "$PROJECT_DIR/.docker-build.env"; then
sudo sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag|" "$PROJECT_DIR/.docker-build.env"
sudo sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=git.invario-software.eu/invario/inventarsystem:$tag|" "$PROJECT_DIR/.docker-build.env"
else
echo "INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" | sudo tee -a "$PROJECT_DIR/.docker-build.env" >/dev/null
echo "INVENTAR_APP_IMAGE=git.invario-software.eu/invario/inventarsystem:$tag" | sudo tee -a "$PROJECT_DIR/.docker-build.env" >/dev/null
fi
backup_legacy_database
echo "Starting stack..."
sudo env INVENTAR_APP_IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" bash "$PROJECT_DIR/start.sh"
sudo env INVENTAR_APP_IMAGE="git.invario-software.eu/invario/inventarsystem:$tag" bash "$PROJECT_DIR/start.sh"
restore_legacy_backup_into_docker
cleanup_old_services
@@ -492,4 +493,4 @@ EOF
echo "Open: https://localhost"
}
main "$@"
main "$@"
+187
View File
@@ -880,6 +880,193 @@ PY
fi
;;
backup)
TENANT_ID="${2:-}"
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
OUTPUT_FILE="${3:-backup_${TENANT_ID}_$(date +%Y%m%d_%H%M%S).zip}"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then
echo "Error: Application container not running."
exit 1
fi
echo "Creating complete backup (.zip) for tenant '$TENANT_ID'..."
# We generate the zip temporarily inside the container
CONTAINER_TMP_ZIP="/tmp/backup_${TENANT_ID}_$(date +%s).zip"
docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY'
import sys, os, zipfile
from bson import json_util
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
from Web.modules.database import settings
from pymongo import MongoClient
tenant_id = sys.argv[1]
zip_path = sys.argv[2]
# CHANGE THIS if your app stores files in a different directory
UPLOADS_BASE = f"/app/uploads/{tenant_id}"
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
try:
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
dump = {}
for coll_name in db.list_collection_names():
if coll_name.startswith('system.'):
continue
dump[coll_name] = list(db[coll_name].find())
db_json = json_util.dumps(dump)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
# 1. Write the database dump
zf.writestr('database.json', db_json)
# 2. Add uploaded photos/invoices if the directory exists
if os.path.isdir(UPLOADS_BASE):
for root, dirs, files in os.walk(UPLOADS_BASE):
for file in files:
file_path = os.path.join(root, file)
# Create a relative path for the zip internal structure
arcname = os.path.relpath(file_path, start=UPLOADS_BASE)
zf.write(file_path, arcname=f"uploads/{arcname}")
except Exception as e:
print(f"Error creating backup: {e}", file=sys.stderr)
sys.exit(1)
PY
if [ $? -eq 0 ]; then
# Copy the zip out of the container to the host
docker cp "$APP_CONTAINER:$CONTAINER_TMP_ZIP" "$OUTPUT_FILE"
# Cleanup inside the container
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
echo "Backup successfully created: $OUTPUT_FILE"
else
echo "Error: Backup failed."
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
exit 1
fi
;;
restore)
TENANT_ID="${2:-}"
INPUT_FILE="${3:-}"
if [ -z "$TENANT_ID" ] || [ -z "$INPUT_FILE" ]; then
echo "Error: Usage: $0 restore <tenant_id> <backup_file.zip>"
exit 1
fi
if [ ! -f "$INPUT_FILE" ]; then
echo "Error: Backup file '$INPUT_FILE' not found."
exit 1
fi
echo -n "WARNING: This will DROP the existing database and completely overwrite data AND files for tenant '$TENANT_ID'. Are you sure? (y/N) "
read confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Restore canceled."
exit 0
fi
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then
echo "Error: Application container not running."
exit 1
fi
echo "Uploading backup archive to container..."
CONTAINER_TMP_ZIP="/tmp/restore_${TENANT_ID}_$(date +%s).zip"
docker cp "$INPUT_FILE" "$APP_CONTAINER:$CONTAINER_TMP_ZIP"
echo "Restoring database and files for tenant '$TENANT_ID'..."
docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY'
import sys, os, zipfile, shutil
from bson import json_util
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
from Web.modules.database import settings
from pymongo import MongoClient
tenant_id = sys.argv[1]
zip_path = sys.argv[2]
# CHANGE THIS if your app stores files in a different directory
UPLOADS_BASE = f"/app/uploads/{tenant_id}"
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
try:
with zipfile.ZipFile(zip_path, 'r') as zf:
# 1. Restore Database
db_json = zf.read('database.json').decode('utf-8')
data = json_util.loads(db_json)
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(db_name)
db = client[db_name]
restored_count = 0
for coll_name, docs in data.items():
if docs:
db[coll_name].insert_many(docs)
restored_count += len(docs)
# 2. Restore Files
# Wipe existing uploads to ensure an exact replica of the backup
if os.path.exists(UPLOADS_BASE):
shutil.rmtree(UPLOADS_BASE)
for item in zf.namelist():
# Extract only file items that were saved under 'uploads/'
if item.startswith('uploads/') and not item.endswith('/'):
rel_path = item[len('uploads/'):]
target_path = os.path.join(UPLOADS_BASE, rel_path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'wb') as f:
f.write(zf.read(item))
print(f"Successfully restored {restored_count} database documents and tenant files.")
except Exception as e:
print(f"Error restoring backup: {e}", file=sys.stderr)
sys.exit(1)
PY
if [ $? -eq 0 ]; then
# Clean up tmp zip inside container
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
echo "Restore completed successfully."
echo "Clearing session cache..."
docker exec "$APP_CONTAINER" python3 -c "
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'inventar_{"".join(c for c in sys.argv[1] if c.isalnum() or c == "_")}']
db.sessions.drop()
" "$TENANT_ID"
else
echo "Error: Restore failed."
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
exit 1
fi
;;
*)
echo "Unknown command: $COMMAND"
show_help
+2 -2
View File
@@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
@@ -676,4 +676,4 @@ fi
verify_stack_health
echo "Stack started."
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+21 -18
View File
@@ -2,18 +2,18 @@
set -euo pipefail
# Release-only updater for Docker deployment.
# Updates are pulled exclusively from GitHub Releases assets.
# Updates are pulled exclusively from Gitea Releases assets.
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$PROJECT_DIR/logs"
LOG_FILE="$LOG_DIR/update.log"
STATE_FILE="$PROJECT_DIR/.release-version"
REPO_SLUG="AIIrondev/legendary-octo-garbanzo"
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
REPO_SLUG="Invario/Inventarsystem"
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
DIST_DIR="$PROJECT_DIR/dist"
COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
@@ -153,7 +153,7 @@ Usage: $0 [options]
Options:
--multitenant Use docker-compose-multitenant.yml (default)
development Install development build from GHCR or local dist
development Install development build from Gitea Registry or local dist
-h, --help Show this help message
EOF
}
@@ -204,14 +204,14 @@ create_backup() {
fi
fi
if [ -x "$PROJECT_DIR/run-backup.sh" ]; then
if "$PROJECT_DIR/run-backup.sh" >> "$LOG_FILE" 2>&1; then
if [ -x "$PROJECT_DIR/backup.sh" ]; then
if "$PROJECT_DIR/backup.sh" --mode auto >> "$LOG_FILE" 2>&1; then
log_message "Backup completed"
else
log_message "WARNING: Backup failed; continuing with release update"
fi
else
log_message "WARNING: run-backup.sh not found; skipping backup"
log_message "WARNING: backup.sh not found; skipping backup"
fi
}
@@ -275,10 +275,10 @@ load_release_image() {
refresh_runtime_scripts_from_main() {
local start_url stop_url restart_url update_url
start_url="https://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
stop_url="https://raw.githubusercontent.com/$REPO_SLUG/main/stop.sh"
restart_url="https://raw.githubusercontent.com/$REPO_SLUG/main/restart.sh"
update_url="https://raw.githubusercontent.com/$REPO_SLUG/main/update.sh"
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
stop_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/stop.sh"
restart_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/restart.sh"
update_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/update.sh"
curl -fsSL "$start_url" -o "$PROJECT_DIR/start.sh" || log_message "WARNING: Could not refresh start.sh from main"
curl -fsSL "$stop_url" -o "$PROJECT_DIR/stop.sh" || log_message "WARNING: Could not refresh stop.sh from main"
@@ -357,7 +357,7 @@ download_and_extract_bundle() {
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
fi
# Neu: Multi-Tenant Ressourcen kopieren, falls vorhanden
# Multi-Tenant Ressourcen kopieren, falls vorhanden
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
fi
@@ -374,7 +374,7 @@ download_and_extract_bundle() {
done
# Ensure executable permissions on all copied scripts
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
@@ -412,7 +412,7 @@ EOF
if ! load_local_dist_image "$tag"; then
if ! load_release_image "$meta_file" "$tag"; then
log_message "Falling back to tagged GHCR image $app_image"
log_message "Falling back to tagged Gitea Registry image $app_image"
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
log_message "Falling back to local Docker build for $app_image"
docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1
@@ -511,7 +511,7 @@ EOF
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
fi
# Try local dist first, then pull from GHCR
# Try local dist first, then pull from Gitea Registry
if ! load_local_dist_image "$tag"; then
log_message "Attempting to pull development image $app_image"
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
@@ -540,7 +540,7 @@ EOF
trap 'rm -rf "${tmp_dir:-}"' EXIT
log_message "Checking latest GitHub release for $REPO_SLUG..."
log_message "Checking latest Gitea release for $REPO_SLUG..."
if ! fetch_release_metadata "$meta_file"; then
log_message "WARNING: Could not fetch release metadata. Falling back to self-healing start path."
if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then
@@ -621,6 +621,9 @@ EOF
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag"
sudo ./opt/Inventarsystem/restart.sh
echo "Restart of the Server Completed"
}
main "$@"
main "$@"