Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7f4b1ce9d | |||
| d84466a3cc | |||
| 0efc01a835 | |||
| 73cf63a56d | |||
| fdcd26326b | |||
| 1fd9e230ac | |||
| cc0c4e28b7 | |||
| ea73b98549 | |||
| d6dda0f25a | |||
| 46176c1741 | |||
| c57b4a9a44 | |||
| 6f424cc8aa | |||
| 103b82af15 | |||
| 69c8aa8700 | |||
| 1fa3dace17 | |||
| 6dced8b1a5 | |||
| 493ee2ea7f | |||
| 52a2ec0cad | |||
| b5f2c3c5c5 | |||
| 403c281c93 | |||
| 6d121f6110 | |||
| 9d940c6151 | |||
| 980b825e07 | |||
| 94326728eb | |||
| efe17883a6 | |||
| 76f93b3d2e | |||
| 8f793045c2 | |||
| a199439d76 | |||
| 6cec483390 | |||
| 10e2f7cc17 | |||
| 0f8c5a0fec | |||
| 091147ecfd | |||
| 7bedfc1558 |
+227
-165
@@ -288,7 +288,7 @@ SCHEDULER_INTERVAL = cfg.SCHEDULER_INTERVAL_MIN
|
||||
SSL_CERT = cfg.SSL_CERT
|
||||
SSL_KEY = cfg.SSL_KEY
|
||||
|
||||
LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media', 'schulbuch']
|
||||
LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'other', 'schoolbook']
|
||||
INVOICE_CURRENCY = 'EUR'
|
||||
|
||||
NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8')))
|
||||
@@ -388,6 +388,8 @@ ALLOWED_COVER_DOMAINS = {
|
||||
"www.googleapis.com"
|
||||
}
|
||||
|
||||
SENSITIVE_AUDIT_FIELDS = ["email", "username", "full_name", "phone", "borrower", "ip"]
|
||||
|
||||
# Apply the configuration for general use throughout the app
|
||||
APP_VERSION = __version__
|
||||
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
|
||||
@@ -658,7 +660,7 @@ def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
|
||||
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
|
||||
return jsonify({'error': message}), 400
|
||||
flash(message, 'error')
|
||||
return redirect(request.referrer or url_for('home'))
|
||||
return redirect(url_for('login'))
|
||||
|
||||
def _get_current_module(path):
|
||||
"""Resolve the active UI module for navbar separation."""
|
||||
@@ -726,9 +728,9 @@ def _append_audit_event_standalone(event_type, payload):
|
||||
al.append_audit_event(
|
||||
db=db,
|
||||
event_type=event_type,
|
||||
actor=encrypt_text(session.get('username', 'system')),
|
||||
payload=encrypt_text(str(payload)),
|
||||
request_ip=encrypt_text(request.remote_addr),
|
||||
actor=session.get('username', 'system'),
|
||||
payload=str(payload),
|
||||
request_ip=request.remote_addr,
|
||||
source='web',
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -1633,7 +1635,7 @@ def allowed_file(filename, file_content=None, max_size_mb=cfg.MAX_UPLOAD_MB):
|
||||
|
||||
file_content.seek(0) # Reset file pointer after reading
|
||||
except Exception as e:
|
||||
error_msg = f"Error validating image content for {filename}: {str(e)}"
|
||||
error_msg = f"Error validating image content for {filename}"
|
||||
app.logger.error(error_msg)
|
||||
|
||||
if extension == 'png':
|
||||
@@ -1673,9 +1675,9 @@ def allowed_file(filename, file_content=None, max_size_mb=cfg.MAX_UPLOAD_MB):
|
||||
except Exception as raw_err:
|
||||
app.logger.error(f"PNG DEBUG: Error during raw file analysis: {str(raw_err)}")
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
return False, f"Datei '{filename}' konnte nicht als Bild erkannt werden. Fehler: {str(e)}"
|
||||
return False, f"Datei '{filename}' konnte nicht als Bild erkannt werden. "
|
||||
|
||||
# Add more content type validations as needed for other file types
|
||||
|
||||
@@ -2833,8 +2835,8 @@ def catch_all_files(filename):
|
||||
# If we get here, the file wasn't found
|
||||
return Response(f"File {filename} not found", status=404)
|
||||
except Exception as e:
|
||||
print(f"Error in catch-all route for {filename}: {str(e)}")
|
||||
return Response(f"Error serving file: {str(e)}", status=500)
|
||||
app.logger.error(f"Error in catch-all route for {filename}: {str(e)}")
|
||||
return Response(f"Error serving file {filename}", status=500)
|
||||
|
||||
"""-------------------------------------------------------------Main Views-----------------------------------------------------------------------------"""
|
||||
|
||||
@@ -3191,7 +3193,7 @@ def api_library_items():
|
||||
ausleihungen_db = db['ausleihungen']
|
||||
|
||||
query = {
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'other', 'schoolbook']},
|
||||
'IsGroupedSubItem': {'$ne': True},
|
||||
'Deleted': {'$ne': True}
|
||||
}
|
||||
@@ -3348,7 +3350,7 @@ def api_library_items():
|
||||
}), 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching library items: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({'error': 'An error occurred while fetching library items'}), 500
|
||||
|
||||
|
||||
@app.route('/api/library_scan_action', methods=['POST'])
|
||||
@@ -3565,15 +3567,20 @@ def api_item_detail(item_id):
|
||||
for rec in borrow_records:
|
||||
if rec.get('Status') == 'active' and rec.get('User'):
|
||||
try:
|
||||
borrower_value = decrypt_text(rec.get('User'))
|
||||
user_raw = rec.get('User')
|
||||
if user_raw is not None:
|
||||
borrower_value = decrypt_text(str(user_raw))
|
||||
except Exception:
|
||||
borrower_value = rec.get('User')
|
||||
borrower_value = str(rec.get('User', ''))
|
||||
break
|
||||
|
||||
if not borrower_value and item.get('User'):
|
||||
try:
|
||||
borrower_value = decrypt_text(item.get('User'))
|
||||
item_user = item.get('User')
|
||||
if item_user is not None:
|
||||
borrower_value = decrypt_text(str(item_user))
|
||||
except Exception:
|
||||
borrower_value = item.get('User')
|
||||
borrower_value = str(item.get('User', ''))
|
||||
|
||||
# Helper to format datetimes
|
||||
def fmt_dt(dt):
|
||||
@@ -3582,38 +3589,46 @@ 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') or ''
|
||||
user_raw = rec.get('User')
|
||||
try:
|
||||
user = decrypt_text(user_raw) if user_raw else ''
|
||||
user = decrypt_text(user_raw) if user_raw is not None else ''
|
||||
except Exception:
|
||||
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>Autor/Künstler:</strong> {html.escape(item.get('Autor', item.get('Author', '-')))}</p>
|
||||
<p><strong>ISBN:</strong> {html.escape(item.get('ISBN', item.get('Code4', '-')))}</p>
|
||||
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
|
||||
<p><strong>Status:</strong> {html.escape(status_label)}</p>
|
||||
<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:
|
||||
app.logger.error(f"Error fetching item detail: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({'error': 'An error occurred while fetching the item detail'}), 500
|
||||
|
||||
|
||||
@app.route('/api/library_item/<item_id>/update', methods=['POST'])
|
||||
@@ -4227,7 +4242,8 @@ def student_card_barcode_download():
|
||||
download_name=f'schuelerausweise_all_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
)
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim PDF-Download: {str(e)}', 'error')
|
||||
app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}")
|
||||
flash('Fehler beim PDF-Download', 'error')
|
||||
return redirect(url_for('student_cards_admin'))
|
||||
|
||||
|
||||
@@ -4399,7 +4415,8 @@ def student_card_single_barcode_download(card_id):
|
||||
download_name=f'ausweis_{card["AusweisId"]}.pdf'
|
||||
)
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim PDF-Download: {str(e)}', 'error')
|
||||
app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}")
|
||||
flash('Fehler beim PDF-Download', 'error')
|
||||
return redirect(url_for('student_cards_admin'))
|
||||
|
||||
|
||||
@@ -4736,7 +4753,7 @@ def get_items():
|
||||
'has_more': pagination_requested and ((offset + count) < total_count)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'items': [], 'error': str(e)}), 500
|
||||
return jsonify({'items': []}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
@@ -4753,7 +4770,8 @@ def get_item_json(id):
|
||||
item['_id'] = str(item['_id'])
|
||||
return jsonify(item)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
app.logger.error(f"Error occurred while fetching item {id}: {e}")
|
||||
return jsonify({'error': 'An error occurred while fetching the item'}), 500
|
||||
|
||||
|
||||
@app.route('/get_bookings')
|
||||
@@ -4827,7 +4845,7 @@ def get_bookings():
|
||||
|
||||
return jsonify({'ok': True, 'bookings': result})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
|
||||
return jsonify({'ok': False, 'bookings': []}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
@@ -4917,7 +4935,7 @@ def get_user_appointments():
|
||||
|
||||
return jsonify({'ok': True, 'bookings': result})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
|
||||
return jsonify({'ok': False, 'bookings': []}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
@@ -4961,7 +4979,8 @@ def api_booking_conflicts():
|
||||
client.close()
|
||||
return jsonify({'conflicts': result, 'count': len(result)})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'conflicts': []}), 500
|
||||
app.logger.error(f"Error occurred while fetching booking conflicts: {e}")
|
||||
return jsonify({'error': 'An error occurred while fetching booking conflicts', 'conflicts': []}), 500
|
||||
|
||||
"""Favorites management endpoints (persistent + session cache)."""
|
||||
def _ensure_session_favs():
|
||||
@@ -5076,7 +5095,8 @@ def debug_favorites():
|
||||
try:
|
||||
db_favs = us.get_favorites(username)
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': f'db_error: {e}', 'session': session_favs})
|
||||
app.logger.error(f"Error fetching DB favorites: {e}")
|
||||
return jsonify({'ok': False, 'error': 'Failed to fetch DB favorites', 'session': session_favs})
|
||||
merged = sorted(set(session_favs) | set(db_favs))
|
||||
return jsonify({'ok': True, 'user': username, 'session': session_favs, 'db': db_favs, 'merged': merged})
|
||||
|
||||
@@ -5137,7 +5157,8 @@ def upload_item():
|
||||
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
||||
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
||||
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
|
||||
|
||||
library_category = sanitize_form_value(request.form.get('library_category', ''))
|
||||
|
||||
try:
|
||||
item_count = int(item_count_raw) if item_count_raw else 1
|
||||
except (TypeError, ValueError):
|
||||
@@ -5149,12 +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()]
|
||||
|
||||
|
||||
# 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):
|
||||
@@ -5184,7 +5205,7 @@ def upload_item():
|
||||
except json.JSONDecodeError as e:
|
||||
app.logger.error(f"Error parsing mobile data: {str(e)}")
|
||||
except Exception as e:
|
||||
error_msg = f"Fehler beim Verarbeiten der Formulardaten: {str(e)}"
|
||||
error_msg = f"Fehler beim Verarbeiten der Formulardaten"
|
||||
app.logger.error(error_msg)
|
||||
if is_mobile:
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
@@ -5206,7 +5227,7 @@ def upload_item():
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
item_isbn = ''
|
||||
item_type = 'general'
|
||||
item_type = 'other'
|
||||
if cfg.MODULES.is_enabled('library') and isbn_raw:
|
||||
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
|
||||
if normalized_isbn:
|
||||
@@ -5214,8 +5235,7 @@ def upload_item():
|
||||
item_type = 'book'
|
||||
else:
|
||||
item_isbn = isbn_raw
|
||||
if upload_mode == 'library':
|
||||
item_type = 'book'
|
||||
|
||||
if item_type_input != "":
|
||||
item_type = item_type_input
|
||||
|
||||
@@ -5232,7 +5252,6 @@ def upload_item():
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('library_admin'))
|
||||
item_type = 'book'
|
||||
|
||||
# Only check for images if not duplicating and no duplicate images provided and no book cover
|
||||
# For library mode, skip this check as images come only from ISBN fetch
|
||||
@@ -5295,7 +5314,7 @@ def upload_item():
|
||||
# Validate every code in our master list against the database
|
||||
for code in all_item_codes:
|
||||
if not it.is_code_unique(code):
|
||||
|
||||
app.logger.info(f"DEBUG: Code '{code}' is not unique.")
|
||||
# Special case: If they only uploaded ONE item, redirect them to that existing item
|
||||
if item_count == 1:
|
||||
existing_item = it._get_items_collection().find_one({"code_4": code})
|
||||
@@ -5328,13 +5347,13 @@ def upload_item():
|
||||
if not base_code:
|
||||
return None
|
||||
|
||||
candidate = base_code if position == 1 else f"{base_code}-{position}"
|
||||
candidate = base_code if position == 1 else f"{base_code}"
|
||||
if it.is_code_unique(candidate):
|
||||
return candidate
|
||||
|
||||
suffix = 1
|
||||
while suffix <= 1000:
|
||||
alternative = f"{candidate}-{suffix}"
|
||||
alternative = f"{candidate}"
|
||||
if it.is_code_unique(alternative):
|
||||
return alternative
|
||||
suffix += 1
|
||||
@@ -5547,7 +5566,7 @@ def upload_item():
|
||||
if is_png:
|
||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Fallback PNG save also failed: {str(fallback_err)}")
|
||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(fallback_err).__name__}")
|
||||
traceback.print_exc()
|
||||
|
||||
error_count += 1
|
||||
continue
|
||||
else:
|
||||
@@ -5655,7 +5674,7 @@ def upload_item():
|
||||
except Exception as webp_err:
|
||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Final WebP conversion failed: {str(webp_err)}")
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
@@ -5714,7 +5733,7 @@ def upload_item():
|
||||
if is_png:
|
||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Could not get PNG dimensions: {str(dim_err)}")
|
||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(dim_err).__name__}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
app.logger.info(f"{image_log_prefix} Starting optimization for {saved_filename} ({original_size/1024:.1f}KB, {original_dimensions})")
|
||||
|
||||
@@ -5764,7 +5783,6 @@ def upload_item():
|
||||
app.logger.warning(f"{image_log_prefix} Optimization failed or returned no file")
|
||||
except Exception as e:
|
||||
app.logger.error(f"{image_log_prefix} Optimization failed: {str(e)}")
|
||||
traceback.print_exc()
|
||||
|
||||
# No fallback thumbnail generation needed as we only use the main image
|
||||
|
||||
@@ -5776,7 +5794,6 @@ def upload_item():
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"{image_log_prefix} Unexpected error: {str(e)}")
|
||||
traceback.print_exc()
|
||||
error_count += 1
|
||||
# Continue with the next image
|
||||
|
||||
@@ -5913,7 +5930,7 @@ def upload_item():
|
||||
app.logger.error(f"Error generating optimized versions for {new_filename}: {e}")
|
||||
# If optimization fails, at least keep the original file
|
||||
result = {'original': new_filename}
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
# If we didn't find the image, use a placeholder
|
||||
else:
|
||||
@@ -5949,7 +5966,7 @@ def upload_item():
|
||||
app.logger.info(f"Processed image {i+1}/{original_count}: {new_filename}")
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error processing image {i+1}/{original_count} ({dup_img}): {str(e)}")
|
||||
traceback.print_exc()
|
||||
|
||||
error_count += 1
|
||||
|
||||
# Log placeholder usage
|
||||
@@ -5987,9 +6004,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:
|
||||
@@ -6030,7 +6044,8 @@ def upload_item():
|
||||
is_grouped_sub_item=(position > 1),
|
||||
parent_item_id=parent_item_id,
|
||||
isbn=item_isbn,
|
||||
item_type=item_type
|
||||
item_type=item_type,
|
||||
library_category=library_category
|
||||
)
|
||||
if not item_id:
|
||||
break
|
||||
@@ -6183,7 +6198,7 @@ def duplicate_item():
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in duplicate_item: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500
|
||||
|
||||
|
||||
@@ -6336,7 +6351,7 @@ def delete_item(id):
|
||||
soft_deleted_borrows = int(delete_result.get('soft_deleted_borrows', 0))
|
||||
archived_files = int((delete_result.get('archive') or {}).get('archived_files', 0))
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error during soft-delete for item group {id}: {str(e)}")
|
||||
app.logger.error(f"Error during soft-delete for item group {id}")
|
||||
delete_success = False
|
||||
archived_files = 0
|
||||
finally:
|
||||
@@ -6468,7 +6483,7 @@ def bulk_delete_items():
|
||||
'group_item_ids': result.get('group_item_ids', []),
|
||||
})
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error during bulk delete: {str(e)}")
|
||||
app.logger.error(f"Error during bulk delete for items {item_ids}: {e}")
|
||||
return jsonify({'success': False, 'message': 'Fehler beim Sammellöschen.'}), 500
|
||||
finally:
|
||||
if client:
|
||||
@@ -6584,23 +6599,89 @@ 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')
|
||||
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
|
||||
@app.route('/update_group', methods=['POST'])
|
||||
def update_group():
|
||||
data = request.get_json()
|
||||
series_group_id = data.get('series_group_id')
|
||||
|
||||
if not series_group_id:
|
||||
return jsonify({'success': False, 'message': 'Keine Gruppen-ID'}), 400
|
||||
|
||||
# 1. Shared Fields (Group Logic)
|
||||
# These apply to every item in the group
|
||||
shared_update = {
|
||||
'Name': data.get('name'),
|
||||
'Ort': data.get('ort'),
|
||||
'Beschreibung': data.get('beschreibung'),
|
||||
'Anschaffungsjahr': data.get('ansch_jahr'),
|
||||
'Anschaffungskosten': data.get('ansch_kost'),
|
||||
'Reservierbar': data.get('reservierbar'),
|
||||
'ISBN': data.get('isbn'),
|
||||
'ItemType': data.get('item_type'),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
|
||||
# 2. Individual Updates (Specific Code Logic)
|
||||
# Expected format: [{'id': '...', 'code_4': '...'}, ...]
|
||||
individual_items = data.get('items', [])
|
||||
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items_col = db['items']
|
||||
|
||||
# A. Apply Shared Attributes to the whole group
|
||||
items_col.update_many(
|
||||
{'SeriesGroupId': series_group_id},
|
||||
{'$set': shared_update}
|
||||
)
|
||||
|
||||
# B. Apply Unique Codes to specific items
|
||||
# We iterate through the provided list to update the specific code for each ID
|
||||
for item in individual_items:
|
||||
item_id = item.get('id')
|
||||
new_code = item.get('code_4')
|
||||
|
||||
if item_id:
|
||||
items_col.update_one(
|
||||
{'_id': ObjectId(item_id), 'SeriesGroupId': series_group_id},
|
||||
{'$set': {'Code_4': new_code}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return jsonify({'success': True, 'message': 'Gruppe und individuelle Codes aktualisiert'})
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error updating group {series_group_id}: {e}")
|
||||
return jsonify({'success': False, 'message': 'Systemfehler beim Update'}), 500
|
||||
|
||||
|
||||
@app.route('/report_damage/<id>', methods=['POST'])
|
||||
def report_damage(id):
|
||||
"""Register a damage report entry for an item (admin only)."""
|
||||
@@ -7199,9 +7280,9 @@ def zurueckgeben(id):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in return process: {e}")
|
||||
app.logger.error(f"Error in return process: {e}")
|
||||
it.update_item_status(id, True)
|
||||
flash(f'Element zurückgegeben, aber ein Fehler ist aufgetreten: {str(e)}', 'warning')
|
||||
flash('Element zurückgegeben, aber ein Fehler ist aufgetreten', 'warning')
|
||||
else:
|
||||
flash('Sie sind nicht berechtigt, dieses Element zurückzugeben, oder es ist bereits verfügbar', 'error')
|
||||
|
||||
@@ -7298,7 +7379,7 @@ def get_planned_bookings(item_id):
|
||||
client.close()
|
||||
return jsonify({'ok': True, 'bookings': bookings})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
return jsonify({'ok': False}), 500
|
||||
|
||||
|
||||
@app.route('/get_planned_bookings_public/<item_id>')
|
||||
@@ -7324,7 +7405,7 @@ def get_planned_bookings_public(item_id):
|
||||
client.close()
|
||||
return jsonify({'ok': True, 'bookings': bookings})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
return jsonify({'ok': False}), 500
|
||||
|
||||
|
||||
@app.route('/check_availability')
|
||||
@@ -7405,7 +7486,7 @@ def check_availability():
|
||||
client.close()
|
||||
return jsonify({'ok': True, 'available': len(conflicts) == 0, 'conflicts': conflicts})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
return jsonify({'ok': False}), 500
|
||||
|
||||
|
||||
# def create_qr_code(id):
|
||||
@@ -7503,8 +7584,9 @@ def plan_booking():
|
||||
if booking_type == 'single':
|
||||
end_date = start_date
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": f"Invalid date format: {e}"}, 400
|
||||
|
||||
app.logger.error(f"Invalid date format: {e}")
|
||||
return {"success": False, "error": "Invalid date format"}, 400
|
||||
|
||||
# Check if item exists
|
||||
item = it.get_item(item_id)
|
||||
if not item:
|
||||
@@ -7576,16 +7658,15 @@ def plan_booking():
|
||||
}
|
||||
else:
|
||||
# All failed
|
||||
return {"success": False, "errors": errors}, 400
|
||||
return {"success": False}, 500
|
||||
else:
|
||||
# All succeeded
|
||||
return {"success": True, "booking_ids": booking_ids}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Error in plan_booking: {e}")
|
||||
traceback.print_exc()
|
||||
return {"success": False, "error": f"Serverfehler: {str(e)}"}, 500
|
||||
app.logger.error(f"Error in plan_booking: {e}")
|
||||
return {"success": False, "error": f"Fehler beim Planen der Buchung"}, 500
|
||||
|
||||
def process_day_bookings(item_id, booking_date, periods, notes):
|
||||
"""
|
||||
@@ -7680,7 +7761,7 @@ def add_booking():
|
||||
|
||||
return jsonify({'success': True, 'booking_id': str(booking_id)})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
return jsonify({'success': False})
|
||||
|
||||
@app.route('/cancel_booking/<id>', methods=['POST'])
|
||||
def cancel_booking(id):
|
||||
@@ -7731,9 +7812,7 @@ def terminplan():
|
||||
|
||||
return render_template('terminplan.html', school_periods=SCHOOL_PERIODS)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Error rendering terminplan: {e}")
|
||||
traceback.print_exc()
|
||||
app.logger.error(f"Error rendering terminplan: {e}")
|
||||
flash('Ein Fehler ist beim Anzeigen des Kalenders aufgetreten.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
|
||||
@@ -7919,15 +7998,17 @@ def delete_user():
|
||||
|
||||
client.close()
|
||||
except Exception as e:
|
||||
flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden: {str(e)}', 'warning')
|
||||
app.logger.error(f"Error resetting borrowings for user {encrypt_text(username)}: {e}")
|
||||
flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden', 'warning')
|
||||
|
||||
# Delete the user
|
||||
try:
|
||||
us.delete_user(username)
|
||||
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Löschen des Benutzers: {str(e)}', 'error')
|
||||
|
||||
app.logger.error(f"Error deleting user {encrypt_text(username)}: {e}")
|
||||
flash('Fehler beim Löschen des Benutzers', 'error')
|
||||
|
||||
return redirect(url_for('user_del'))
|
||||
|
||||
|
||||
@@ -8025,7 +8106,7 @@ def admin_verify_audit_chain():
|
||||
status_code = 200 if result.get('ok') else 409
|
||||
return jsonify(result), status_code
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
return jsonify({'ok': False, 'error': 'Internal server error'}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
@@ -8044,23 +8125,14 @@ def admin_audit_dashboard():
|
||||
al.ensure_audit_indexes(db)
|
||||
verify_result = al.verify_audit_chain(db)
|
||||
|
||||
audit_rows = list(
|
||||
db['audit_log'].find(
|
||||
{},
|
||||
{
|
||||
'chain_index': 1,
|
||||
'event_type': 1,
|
||||
'actor': 1,
|
||||
'source': 1,
|
||||
'ip': 1,
|
||||
'timestamp': 1,
|
||||
'created_at': 1,
|
||||
'entry_hash': 1,
|
||||
'prev_hash': 1,
|
||||
'payload': 1,
|
||||
}
|
||||
).sort('chain_index', -1).limit(200)
|
||||
)
|
||||
audit_rows = list(db['audit_log'].find({}).sort('chain_index', -1).limit(200))
|
||||
|
||||
# DEC_START: Decrypt the sensitive fields for display
|
||||
for row in audit_rows:
|
||||
if "payload" in row:
|
||||
# decrypt_document_fields acts in-place
|
||||
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
|
||||
# DEC_END
|
||||
|
||||
return render_template(
|
||||
'admin_audit.html',
|
||||
@@ -8105,28 +8177,18 @@ def admin_audit_export_pdf_official():
|
||||
])
|
||||
)
|
||||
|
||||
audit_rows = list(
|
||||
db['audit_log'].find(
|
||||
{},
|
||||
{
|
||||
'chain_index': 1,
|
||||
'event_type': 1,
|
||||
'actor': 1,
|
||||
'source': 1,
|
||||
'ip': 1,
|
||||
'timestamp': 1,
|
||||
'created_at': 1,
|
||||
'entry_hash': 1,
|
||||
'prev_hash': 1,
|
||||
'payload': 1,
|
||||
}
|
||||
).sort('chain_index', -1).limit(limit)
|
||||
)
|
||||
audit_rows = list(db['audit_log'].find({}).sort('chain_index', -1).limit(limit))
|
||||
|
||||
# DEC_START: Decrypt sensitive fields for the PDF report
|
||||
for row in audit_rows:
|
||||
if "payload" in row:
|
||||
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
|
||||
# DEC_END
|
||||
|
||||
# Get school information from settings or use defaults
|
||||
school_info = _get_school_info_for_export()
|
||||
|
||||
# Generate PDF
|
||||
# Generate PDF with now-decrypted audit_rows
|
||||
pdf_content = pdf_export.generate_audit_pdf(
|
||||
verify_result=verify_result,
|
||||
event_counts=event_counts,
|
||||
@@ -8134,7 +8196,7 @@ def admin_audit_export_pdf_official():
|
||||
export_type='official',
|
||||
school_info=school_info
|
||||
)
|
||||
|
||||
|
||||
response = make_response(pdf_content)
|
||||
response.headers['Content-Type'] = 'application/pdf'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=audit-official-report-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.pdf'
|
||||
@@ -8142,7 +8204,7 @@ def admin_audit_export_pdf_official():
|
||||
|
||||
except Exception as exc:
|
||||
app.logger.error(f"PDF Official Report export error: {str(exc)}\n{traceback.format_exc()}")
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
return jsonify({'ok': False, 'error': 'Internal server error'}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
@@ -8205,7 +8267,7 @@ def admin_image_cache_stats():
|
||||
})
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error getting cache stats: {str(e)}")
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
return jsonify({'ok': False}), 500
|
||||
|
||||
|
||||
@app.route('/admin/image_cache_cleanup', methods=['POST'])
|
||||
@@ -8254,7 +8316,7 @@ def admin_image_cache_cleanup():
|
||||
})
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error during image cache cleanup: {str(e)}")
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
return jsonify({'ok': False}), 500
|
||||
|
||||
"""-----------------------------------------------------------Borrowing Management Routes-------------------------------------------------------"""
|
||||
|
||||
@@ -8323,7 +8385,8 @@ def admin_reset_borrowing(borrow_id):
|
||||
|
||||
client.close()
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Zurücksetzen: {str(e)}', 'error')
|
||||
app.logger.error(f"Error resetting borrowing status for {borrow_id}: {e}")
|
||||
flash('Fehler beim Zurücksetzen', 'error')
|
||||
|
||||
return redirect(url_for('admin_borrowings'))
|
||||
|
||||
@@ -8937,10 +9000,11 @@ def admin_reset_user_password():
|
||||
# Reset the password
|
||||
try:
|
||||
us.update_password(username, new_password)
|
||||
flash(f'Passwort für {username} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
|
||||
flash(f'Passwort für {encrypt_text(username)} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Zurücksetzen des Passworts: {str(e)}', 'error')
|
||||
|
||||
app.logger.error(f'Error resetting password for {encrypt_text(username)}: {e}')
|
||||
flash('Fehler beim Zurücksetzen des Passworts', 'error')
|
||||
|
||||
return redirect(url_for('user_del'))
|
||||
|
||||
|
||||
@@ -9440,7 +9504,8 @@ def search_word(word):
|
||||
|
||||
return jsonify({"success": True, "response": list(id_set)})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "response": str(e)})
|
||||
app.logger.error(f"Error searching for word: {e}")
|
||||
return jsonify({"success": False})
|
||||
|
||||
def _fetch_from_google_books(clean_isbn):
|
||||
"""Source 1: Google Books API (Free, No Key required for basic use)"""
|
||||
@@ -9739,8 +9804,8 @@ def fetch_book_info(isbn):
|
||||
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching book data: {e}")
|
||||
return jsonify({"error": f"Failed to fetch book information: {str(e)}"}), 500
|
||||
app.logger.error(f"Error fetching book data: {e}")
|
||||
return jsonify({"error": f"Failed to fetch book information"}), 500
|
||||
|
||||
@app.route('/download_book_cover', methods=['POST'])
|
||||
def download_book_cover():
|
||||
@@ -9824,12 +9889,12 @@ def download_book_cover():
|
||||
})
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Network error downloading book cover: {e}")
|
||||
app.logger.error(f"Network error downloading book cover: {e}")
|
||||
return jsonify({"error": "Netzwerkfehler beim Herunterladen des Bildes."}), 500
|
||||
except Exception as e:
|
||||
print(f"Error downloading book cover: {e}")
|
||||
app.logger.error(f"Error downloading book cover: {e}")
|
||||
# Fixed syntax here: Removed the injected HTML that was appended to this line
|
||||
return jsonify({"error": f"Failed to download image: {str(e)}"}), 500
|
||||
return jsonify({"error": f"Failed to download image"}), 500
|
||||
"""
|
||||
@app.route('/proxy_image')
|
||||
def proxy_image():
|
||||
@@ -9888,8 +9953,8 @@ def proxy_image():
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error in proxy_image: {e}")
|
||||
return jsonify({"error": f"Error fetching image: {str(e)}"}), 500
|
||||
app.logger.error(f"Error in proxy_image: {e}")
|
||||
return jsonify({"error": f"Error fetching image"}), 500
|
||||
"""
|
||||
|
||||
|
||||
@@ -10737,8 +10802,8 @@ def schedule_appointment():
|
||||
if has_conflict:
|
||||
return jsonify({'success': False, 'message': 'Termin kollidiert mit bestehender Buchung'}), 409
|
||||
except Exception as e:
|
||||
print(f"Error checking for booking conflicts: {e}")
|
||||
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit: {str(e)}'}), 500
|
||||
app.logger.error(f"Error checking for booking conflicts: {e}")
|
||||
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit'}), 500
|
||||
|
||||
# Check if the appointment should already be active
|
||||
now = datetime.datetime.now()
|
||||
@@ -10809,8 +10874,8 @@ def schedule_appointment():
|
||||
if not appointment_id:
|
||||
return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500
|
||||
except Exception as e:
|
||||
print(f"Error creating booking: {e}")
|
||||
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins: {str(e)}'}), 500
|
||||
app.logger.error(f"Error creating booking: {e}")
|
||||
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins'}), 500
|
||||
|
||||
# If we got this far, we have a valid appointment_id
|
||||
try:
|
||||
@@ -10844,14 +10909,14 @@ def schedule_appointment():
|
||||
return jsonify({'success': False, 'message': 'Element konnte nicht mit Termininformationen aktualisiert werden'}), 500
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating item with appointment info: {e}")
|
||||
return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements: {str(e)}'}), 500
|
||||
app.logger.error(f"Error updating item with appointment info: {e}")
|
||||
return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements'}), 500
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating appointment: {e}")
|
||||
app.logger.error(f"Error creating appointment: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': f'Serverfehler aufgetreten: {str(e)}'}), 500
|
||||
|
||||
return jsonify({'success': False, 'message': f'Serverfehler aufgetreten'}), 500
|
||||
|
||||
@app.route('/cancel_ausleihung/<id>', methods=['POST'])
|
||||
def cancel_ausleihung_route(id):
|
||||
@@ -10925,16 +10990,15 @@ def cancel_ausleihung_route(id):
|
||||
next_appt = item_doc.get('NextAppointment', {})
|
||||
if next_appt and str(next_appt.get('appointment_id')) == str(id):
|
||||
cleared = it.clear_item_next_appointment(item_id)
|
||||
print(f"Cleared NextAppointment for item {item_id}: {cleared}")
|
||||
except Exception as clear_err:
|
||||
print(f"Warning: could not clear NextAppointment for cancelled ausleihung {id}: {clear_err}")
|
||||
app.logger.warning(f"Warning: could not clear NextAppointment for cancelled ausleihung {id}: {clear_err}")
|
||||
else:
|
||||
print(f"Failed to cancel ausleihung with ID: {id}")
|
||||
app.logger.warning(f"Failed to cancel ausleihung with ID: {id}")
|
||||
flash('Fehler beim Stornieren der Ausleihung', 'error')
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error canceling ausleihung: {e}")
|
||||
flash(f'Fehler: {str(e)}', 'error')
|
||||
app.logger.warning(f"Error canceling ausleihung: {e}")
|
||||
flash(f'Fehler', 'error')
|
||||
|
||||
return redirect(url_for('my_borrowed_items'))
|
||||
|
||||
@@ -10965,22 +11029,21 @@ def reset_item(id):
|
||||
if result['success']:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': result['message'],
|
||||
'details': result.get('details', {})
|
||||
'message': 'Item reset successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': result['message']
|
||||
'message': 'Failed to reset item'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in reset_item route: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Serverfehler: {str(e)}'
|
||||
'error': f'Serverfehler'
|
||||
}), 500
|
||||
|
||||
# New image and video optimization functions
|
||||
@@ -11126,7 +11189,6 @@ def create_image_thumbnail(image_path, thumbnail_path, size, debug_prefix=""):
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating image thumbnail for {image_path}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -11167,11 +11229,11 @@ def create_video_thumbnail(video_path, thumbnail_path, size):
|
||||
|
||||
return success
|
||||
else:
|
||||
print(f"ffmpeg failed for {video_path}: {result.stderr}")
|
||||
app.logger.error(f"ffmpeg failed for {video_path}: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating video thumbnail for {video_path}: {str(e)}")
|
||||
app.logger.error(f"Error creating video thumbnail for {video_path}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -11398,7 +11460,7 @@ def generate_optimized_versions(filename, max_original_width=500, target_size_kb
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"{log_prefix} Failed to process image: {str(e)}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Just copy the original file as is
|
||||
if not is_webp_ext and os.path.exists(original_path) and not os.path.exists(converted_path):
|
||||
try:
|
||||
@@ -11427,7 +11489,7 @@ def generate_optimized_versions(filename, max_original_width=500, target_size_kb
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"{log_prefix} Unhandled exception in optimization: {str(e)}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
# If anything went wrong but the original file exists, just use it
|
||||
if os.path.exists(original_path):
|
||||
@@ -11663,7 +11725,7 @@ def cleanup_old_optimized_images(max_age_days=30):
|
||||
}
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error during optimized image cleanup: {str(e)}")
|
||||
return {'deleted': 0, 'freed_mb': 0, 'error': str(e)}
|
||||
return {'deleted': 0, 'freed_mb': 0}
|
||||
|
||||
|
||||
@app.route('/log_mobile_issue', methods=['POST'])
|
||||
@@ -11708,7 +11770,7 @@ def log_mobile_issue():
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error logging mobile issue: {str(e)}")
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
return jsonify({'success': False})
|
||||
|
||||
def delete_item_images(filenames):
|
||||
"""
|
||||
@@ -11860,7 +11922,7 @@ def subscribe_to_push():
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'Error subscribing to push: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return jsonify({'success': False}), 500
|
||||
|
||||
|
||||
@app.route('/api/push/unsubscribe', methods=['POST'])
|
||||
@@ -11897,7 +11959,7 @@ def unsubscribe_from_push():
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'Error unsubscribing from push: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return jsonify({'success': False}), 500
|
||||
|
||||
|
||||
@app.route('/api/push/subscriptions', methods=['GET'])
|
||||
@@ -11931,7 +11993,7 @@ def get_push_subscriptions():
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'Error getting push subscriptions: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return jsonify({'success': False}), 500
|
||||
|
||||
|
||||
@app.route('/api/push/vapid-key', methods=['GET'])
|
||||
@@ -11956,7 +12018,7 @@ def get_vapid_key():
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'Error getting VAPID key: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return jsonify({'success': False}), 500
|
||||
|
||||
|
||||
@app.route('/api/push/test', methods=['POST'])
|
||||
@@ -11995,4 +12057,4 @@ def test_push_notification():
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'Error sending test push: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return jsonify({'success': False}), 500
|
||||
|
||||
@@ -1235,5 +1235,5 @@ def reset_item_completely(item_id):
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Fehler beim Zurücksetzen: {str(e)}'
|
||||
'message': f'Fehler beim Zurücksetzen.'
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'other', 'schoolbook')
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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,6 +100,7 @@ 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,
|
||||
'SeriesGroupId': series_group_id,
|
||||
'SeriesCount': series_count,
|
||||
@@ -194,69 +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'):
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -475,7 +475,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>
|
||||
@@ -1033,7 +1033,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;
|
||||
}
|
||||
|
||||
@@ -1057,7 +1057,7 @@
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// =========================================================================
|
||||
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
||||
// =========================================================================
|
||||
function wireScannerUi() {
|
||||
@@ -1192,75 +1192,194 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Modal Control Functions (accessible globally from table buttons)
|
||||
function openEditLibraryItem(itemId) {
|
||||
window.openEditLibraryItem = function(itemId) {
|
||||
const item = libraryItems.find(i => i._id === itemId);
|
||||
|
||||
if (!item) {
|
||||
alert('Fehler: Das Medium konnte nicht gefunden werden.');
|
||||
return;
|
||||
if (!item) return;
|
||||
|
||||
// 1. Felder befüllen
|
||||
document.getElementById('editLibraryItemId').value = item._id;
|
||||
document.getElementById('editLibraryName').value = item.Name;
|
||||
document.getElementById('editLibraryType').value = item.ItemType;
|
||||
document.getElementById('editLibraryIsbn').value = item.ISBN || '';
|
||||
document.getElementById('editLibraryCode4').value = item.Code_4 || '';
|
||||
document.getElementById('editLibraryLocation').value = item.Ort;
|
||||
document.getElementById('editLibraryDescription').value = item.Beschreibung;
|
||||
|
||||
// 2. Gruppen-Logik
|
||||
const warningDiv = document.getElementById('editLibraryGroupWarning');
|
||||
const codesContainer = document.getElementById('editLibraryAllCodes');
|
||||
|
||||
if (item.SeriesGroupId) {
|
||||
// Filtern aus dem aktuell geladenen Array
|
||||
let groupMembers = libraryItems.filter(i => i.SeriesGroupId === item.SeriesGroupId);
|
||||
|
||||
// SCHLÜSSEL: Wenn die Anzahl der gefundenen Elemente nicht mit SeriesCount übereinstimmt,
|
||||
// haben wir die Gruppe noch nicht vollständig geladen.
|
||||
if (groupMembers.length < (item.SeriesCount || 0)) {
|
||||
console.warn("Gruppe noch nicht vollständig geladen. Anzeige ggf. unvollständig.");
|
||||
// Optional: Zeige einen Ladehinweis im Modal
|
||||
codesContainer.textContent = "Lade restliche Gruppenmitglieder...";
|
||||
} else {
|
||||
// Daten sind vollständig -> Anzeigen
|
||||
const codeList = groupMembers
|
||||
.sort((a, b) => (a.SeriesPosition || 0) - (b.SeriesPosition || 0))
|
||||
.map(m => m.Code_4 || "---")
|
||||
.join(', ');
|
||||
|
||||
codesContainer.textContent = codeList;
|
||||
}
|
||||
|
||||
document.getElementById('editLibraryGroupCount').textContent = groupMembers.length + " / " + (item.SeriesCount || "?");
|
||||
warningDiv.style.display = 'block';
|
||||
} else {
|
||||
warningDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
// Populate the form fields - Using correct German properties from MongoDB fields
|
||||
document.getElementById('editLibraryItemId').value = item._id || '';
|
||||
document.getElementById('editLibraryName').value = item.Name || '';
|
||||
document.getElementById('editLibraryType').value = item.ItemType || 'book';
|
||||
document.getElementById('editLibraryIsbn').value = item.ISBN || '';
|
||||
document.getElementById('editLibraryCode4').value = item.Code_4 || item.Code4 || '';
|
||||
document.getElementById('editLibraryLocation').value = item.Ort || '';
|
||||
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
|
||||
|
||||
document.getElementById('editLibraryModal').style.display = 'flex';
|
||||
}
|
||||
document.getElementById('editLibraryModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
function closeEditLibraryModal() {
|
||||
document.getElementById('editLibraryModal').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-Listener für das Formular (Initialisierung)
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const editForm = document.getElementById('editLibraryForm');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const itemId = document.getElementById('editLibraryItemId').value;
|
||||
const currentItem = libraryItems.find(i => i._id === itemId);
|
||||
|
||||
if (!currentItem) return;
|
||||
|
||||
// 1. Alle Mitglieder der Gruppe finden, um die Code-Liste aufzubauen
|
||||
const groupMembers = libraryItems.filter(i => i.SeriesGroupId === currentItem.SeriesGroupId);
|
||||
const individualUpdates = groupMembers.map(member => ({
|
||||
id: member._id,
|
||||
// Wenn dies das bearbeitete Item ist, nimm den neuen Code, sonst den alten
|
||||
code_4: (member._id === itemId) ? document.getElementById('editLibraryCode4').value : member.Code_4
|
||||
}));
|
||||
|
||||
// 2. Payload für das Backend bauen
|
||||
const payload = {
|
||||
series_group_id: currentItem.SeriesGroupId,
|
||||
name: document.getElementById('editLibraryName').value,
|
||||
ort: document.getElementById('editLibraryLocation').value,
|
||||
beschreibung: document.getElementById('editLibraryDescription').value,
|
||||
isbn: document.getElementById('editLibraryIsbn').value,
|
||||
item_type: document.getElementById('editLibraryType').value,
|
||||
items: individualUpdates
|
||||
};
|
||||
|
||||
// 3. Request an die Gruppen-Update Route
|
||||
try {
|
||||
const response = await fetch('/update_group', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert('Gruppe erfolgreich synchronisiert!');
|
||||
closeEditLibraryModal();
|
||||
await loadLibraryItems(); // Daten neu laden
|
||||
// renderTable(); // Ggf. Tabelle neu rendern
|
||||
} else {
|
||||
alert('Fehler: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
alert('Netzwerkfehler.');
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="editLibraryModal" class="modal" style="display:none;">
|
||||
<div class="modal-content" style="max-width: 760px;">
|
||||
<span class="close" onclick="closeEditLibraryModal()">×</span>
|
||||
<div class="modal-content" style="max-width: 760px; padding: 25px; border-radius: 8px;">
|
||||
<span class="close" onclick="closeEditLibraryModal()" style="cursor: pointer; float: right; font-size: 24px;">×</span>
|
||||
<h3 style="margin-top:0;">Bibliotheksmedium bearbeiten</h3>
|
||||
|
||||
<!-- Bereich für Gruppen-Informationen (Hier konsolidiert!) -->
|
||||
<div id="editLibraryGroupWarning" style="display:none; background-color: #fff; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #0ea5e9;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
|
||||
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
|
||||
</div>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 12px; color: #555;">
|
||||
Alle aufgeführten Codes gehören zu diesem Datensatz:
|
||||
</p>
|
||||
|
||||
<!-- Hier werden die Codes per JS eingefügt -->
|
||||
<div id="editLibraryAllCodes" style="display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px;"></div>
|
||||
|
||||
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
|
||||
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="editLibraryForm">
|
||||
<input type="hidden" id="editLibraryItemId">
|
||||
<div class="edit-grid">
|
||||
<div class="full">
|
||||
<label for="editLibraryName">Titel</label>
|
||||
<input id="editLibraryName" required>
|
||||
<input id="editLibraryName" required style="width: 100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="editLibraryType">Medientyp</label>
|
||||
<select id="editLibraryType">
|
||||
<select id="editLibraryType" style="width: 100%;">
|
||||
<option value="book">Buch</option>
|
||||
<option value="schulbuch">Schulbuch</option>
|
||||
<option value="schoolbook">Schulbuch</option>
|
||||
<option value="cd">CD</option>
|
||||
<option value="dvd">DVD</option>
|
||||
<option value="media">Sonstige Medien</option>
|
||||
<option value="other">Sonstige Medien</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editLibraryIsbn">ISBN</label>
|
||||
<input id="editLibraryIsbn" placeholder="optional ISBN-10/13">
|
||||
<input id="editLibraryIsbn" placeholder="optional ISBN-10/13" style="width: 100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="editLibraryCode4">Code</label>
|
||||
<input id="editLibraryCode4" placeholder="optional Mediencode">
|
||||
<input id="editLibraryCode4" placeholder="optional Mediencode" style="width: 100%;">
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="editLibraryLocation">Ort</label>
|
||||
<input id="editLibraryLocation" required>
|
||||
<input id="editLibraryLocation" required style="width: 100%;">
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="editLibraryDescription">Beschreibung</label>
|
||||
<textarea id="editLibraryDescription" rows="4" required></textarea>
|
||||
<textarea id="editLibraryDescription" rows="4" required style="width: 100%;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern</button>
|
||||
<div class="edit-actions" style="margin-top:20px;">
|
||||
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern & Synchronisieren</button>
|
||||
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editLibraryGroupWarning" style="display:none; background-color: #fff; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #0ea5e9;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
|
||||
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
|
||||
</div>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 12px; color: #555;">
|
||||
Alle Codes in dieser Gruppe:
|
||||
</p>
|
||||
|
||||
<!-- Hier wird die Liste als Komma-Text eingefügt -->
|
||||
<div id="editLibraryAllCodes" style="font-family: monospace; font-size: 14px; font-weight: bold; color: #333; margin-top: 5px;"></div>
|
||||
|
||||
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
|
||||
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user