Compare commits

...

5 Commits

Author SHA1 Message Date
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
4 changed files with 343 additions and 72 deletions
+32 -30
View File
@@ -3589,40 +3589,41 @@ def api_item_detail(item_id):
except Exception:
return str(dt) if dt else ''
# Build HTML for borrow records (if any)
borrows_html = ''
if borrow_records:
rows = []
for rec in borrow_records:
user_raw = rec.get('User')
try:
# FIX: Cast to string before decrypting
user = decrypt_text(str(user_raw)) if user_raw is not None else ''
user = decrypt_text(user_raw) if user_raw is not None else ''
except Exception:
# FIX: Ensure the fallback is also a string for html.escape
user = str(user_raw) if user_raw is not None else ''
user = user_raw
status = rec.get('Status', '')
start = fmt_dt(rec.get('Start'))
end = fmt_dt(rec.get('End'))
notes = html.escape(str(rec.get('Notes') or ''))
rows.append(f"<li><strong>{html.escape(user or '-')}</strong> — {html.escape(status)}{html.escape(start)}{html.escape(end)}{('' + notes) if notes else ''}</li>")
rows.append(
f"<li><strong>{html.escape(str(user or '-'))}</strong> — "
f"{html.escape(str(status))}"
f"{html.escape(str(start))}{html.escape(str(end))}"
f"{('' + notes) if notes else ''}</li>"
)
borrows_html = f"<h3>Ausleihhistorie</h3><ul>{''.join(rows)}</ul>"
# Basic detail HTML
detail_html = f"""
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
<p><strong>ISBN:</strong> {html.escape(item.get('ISBN', item.get('Code4', '-')))}</p>
<p><strong>Anzahl:</strong> {html.escape(item.get('SeriesCount', '-'))}</p>
<p><strong>Ort:</strong> {html.escape(item.get('Ort', '-'))}</p>
<p><strong>Typ:</strong> {html.escape(item.get('ItemType', '-'))}</p>
<p><strong>Kategorie:</strong> {html.escape(item.get('library_category', '-'))}</p>
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
<p><strong>Status:</strong> {html.escape(status_label)}</p>
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(borrower_value)}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
<h2>{html.escape(str(item.get('Name', 'Untitled')))}</h2>
<p><strong>ISBN:</strong> {html.escape(str(item.get('ISBN', item.get('Code4', '-'))))}</p>
<p><strong>Anzahl:</strong> {html.escape(str(item.get('SeriesCount', '-')))}</p>
<p><strong>Ort:</strong> {html.escape(str(item.get('Ort', '-')))}</p>
<p><strong>Typ:</strong> {html.escape(str(item.get('ItemType', '-')))}</p>
<p><strong>Kategorie:</strong> {html.escape(str(item.get('library_category', '-')))}</p>
<p><strong>Beschreibung:</strong> {html.escape(str(item.get('Beschreibung', '-')))}</p>
<p><strong>Status:</strong> {html.escape(str(status_label))}</p>
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
{borrows_html}
"""
client.close()
return detail_html, 200
except Exception as e:
@@ -5158,7 +5159,6 @@ def upload_item():
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
library_category = sanitize_form_value(request.form.get('library_category', ''))
app.logger.info(f"Upload attempt by {encrypt_text(username)}: name={name!r}, ort={ort!r}, beschreibung length={len(beschreibung)}, images count={len(images)}, filters={filter_upload}, filters2={filter_upload2}, filters3={filter_upload3}, anschaffungs_jahr={anschaffungs_jahr}, anschaffungs_kosten={anschaffungs_kosten}, code_4={code_4}, isbn={isbn_raw!r}, upload_mode={upload_mode!r}, item_count={item_count_raw!r}, item_type_input={item_type_input!r}, individual_codes={individual_codes_raw!r}")
try:
item_count = int(item_count_raw) if item_count_raw else 1
except (TypeError, ValueError):
@@ -5170,14 +5170,12 @@ def upload_item():
if individual_codes_raw:
individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if c.strip()]
app.logger.info(f"DEBUG: Parsed individual codes: {individual_codes}, count: {len(individual_codes)}")
# Check if this is a duplication
is_duplicating = request.form.get('is_duplicating') == 'true'
# Get duplicate_images if duplicating
duplicate_images = request.form.getlist('duplicate_images') if is_duplicating else []
print(f"DEBUG: Duplicate images from form: {duplicate_images}, count: {len(duplicate_images)}")
# Make sure duplicate_images is always a list, even if there's only one
if is_duplicating and duplicate_images and not isinstance(duplicate_images, list):
@@ -5287,8 +5285,6 @@ def upload_item():
for c in extra_codes:
if c not in all_item_codes:
all_item_codes.append(c)
app.logger.info(f"DEBUG: Unified item codes: {all_item_codes}, total count: {len(all_item_codes)}")
# -------------------------------------------------------------------
# 2. OVERRIDE ITEM COUNT
@@ -6010,9 +6006,6 @@ def upload_item():
else:
app.logger.warning(f"Book cover image not found: {book_cover_image}")
# Log image processing stats
app.logger.info(f"Upload stats: processed={processed_count}, errors={error_count}, skipped={skipped_count}, duplicates={len(duplicate_images) if duplicate_images else 0}")
# If location is not in the predefined list, add it
predefined_locations = it.get_predefined_locations()
if ort and ort not in predefined_locations:
@@ -6608,17 +6601,26 @@ def edit_item(id):
if ort and ort not in predefined_locations:
it.add_predefined_location(ort)
# Update the item
result = it.update_item(
id, name, ort, beschreibung,
images, verfuegbar, filter1, filter2, filter3,
anschaffungs_jahr, anschaffungs_kosten, code_4, reservierbar,
id=id,
name=name,
ort=ort,
beschreibung=beschreibung,
images=images,
verfuegbar=verfuegbar,
filter1=filter1,
filter2=filter2,
filter3=filter3,
ansch_jahr=anschaffungs_jahr,
ansch_kost=anschaffungs_kosten,
code_4=code_4,
reservierbar=reservierbar,
isbn=item_isbn,
item_type=item_type
)
if result:
flash('Element erfolgreich aktualisiert', 'success')
flash('Element erfolgreich aktualisiert (und ggf. Gruppe synchronisiert)', 'success')
else:
flash('Fehler beim Aktualisieren des Elements', 'error')
+30 -39
View File
@@ -198,70 +198,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', library_category=None):
"""
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
library_category (str, optional): Library category for the item
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,
'library_category': library_category,
'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.
+94 -3
View File
@@ -1192,7 +1192,6 @@
}
});
// Modal Control Functions (accessible globally from table buttons)
function openEditLibraryItem(itemId) {
const item = libraryItems.find(i => i._id === itemId);
@@ -1201,7 +1200,7 @@
return;
}
// Populate the form fields - Using correct German properties from MongoDB fields
// Felder befüllen
document.getElementById('editLibraryItemId').value = item._id || '';
document.getElementById('editLibraryName').value = item.Name || '';
document.getElementById('editLibraryType').value = item.ItemType || 'book';
@@ -1210,18 +1209,110 @@
document.getElementById('editLibraryLocation').value = item.Ort || '';
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
// Gruppen-Info-Box Logik
const warningDiv = document.getElementById('editLibraryGroupWarning');
const codesContainer = document.getElementById('editLibraryAllCodes');
if (item.SeriesGroupId) {
const groupMembers = libraryItems
.filter(i => i.SeriesGroupId === item.SeriesGroupId)
.sort((a, b) => a.SeriesPosition - b.SeriesPosition);
document.getElementById('editLibraryGroupCount').textContent = groupMembers.length;
codesContainer.innerHTML = groupMembers.map(member => {
const isCurrent = member._id === itemId;
return `<span style="background: ${isCurrent ? '#0ea5e9' : '#e0e0e0'}; color: ${isCurrent ? '#fff' : '#333'}; padding: 2px 8px; border-radius: 4px; margin-right: 5px; font-size: 12px;">${member.Code_4 || 'n/a'}</span>`;
}).join('');
warningDiv.style.display = 'block';
} else {
warningDiv.style.display = 'none';
}
document.getElementById('editLibraryModal').style.display = 'flex';
}
function closeEditLibraryModal() {
document.getElementById('editLibraryModal').style.display = 'none';
}
// --- Sicherer Event-Listener für das Formular ---
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 updateData = {
id: itemId,
name: document.getElementById('editLibraryName').value,
item_type: document.getElementById('editLibraryType').value,
isbn: document.getElementById('editLibraryIsbn').value,
code_4: document.getElementById('editLibraryCode4').value,
ort: document.getElementById('editLibraryLocation').value,
beschreibung: document.getElementById('editLibraryDescription').value
};
try {
const response = await fetch('/api/update-item', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
if (response.ok) {
const editedItem = libraryItems.find(i => i._id === itemId);
// Lokale Daten im Array synchronisieren
if (editedItem && editedItem.SeriesGroupId) {
libraryItems.forEach(item => {
if (item.SeriesGroupId === editedItem.SeriesGroupId) {
item.Name = updateData.name;
item.ItemType = updateData.item_type;
item.ISBN = updateData.isbn;
item.Ort = updateData.ort;
item.Beschreibung = updateData.beschreibung;
if (item._id === itemId) item.Code_4 = updateData.code_4;
}
});
} else if (editedItem) {
Object.assign(editedItem, updateData);
}
closeEditLibraryModal();
// Optional: renderLibraryTable();
} else {
const err = await response.json();
alert('Fehler beim Speichern: ' + (err.error || 'Unbekannter Fehler'));
}
} catch (error) {
console.error('Fetch error:', 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>
<h3 style="margin-top:0;">Bibliotheksmedium bearbeiten</h3>
<!-- Bereich für Gruppen-Informationen -->
<div id="editLibraryGroupWarning" style="display:none; background-color: #f8f9fa; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #dee2e6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong style="color: #0ea5e9;">Gruppen-Synchronisierung aktiv</strong>
<span style="font-size: 12px; color: #666;">Gesamt: <span id="editLibraryGroupCount"></span> Exemplare</span>
</div>
<p style="margin: 0 0 10px 0; font-size: 13px;">Codes in dieser Range:</p>
<div id="editLibraryAllCodes" style="display: flex; flex-wrap: wrap; gap: 5px;"></div>
</div>
<form id="editLibraryForm">
<input type="hidden" id="editLibraryItemId">
<div class="edit-grid">
@@ -1256,7 +1347,7 @@
<textarea id="editLibraryDescription" rows="4" required></textarea>
</div>
</div>
<div class="edit-actions">
<div class="edit-actions" style="margin-top:20px;">
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern</button>
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
</div>
+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