Compare commits

...

23 Commits

Author SHA1 Message Date
Aiirondev_dev 170a2a53ab Reduce Gunicorn worker count and adjust timeout settings for optimized performance 2026-04-12 20:57:41 +02:00
Aiirondev_dev a8ac08d5b0 Implement light mode for item loading to optimize initial data fetch 2026-04-12 20:36:13 +02:00
Aiirondev_dev 05c4c73635 Add loading indicator for main items in admin view to enhance user experience 2026-04-12 20:21:47 +02:00
Aiirondev_dev 76dd00831e Enhance item count display logic in admin view for improved accuracy 2026-04-12 20:02:19 +02:00
Aiirondev_dev 205a56ff24 Update filter display function calls for improved clarity in UI updates 2026-04-12 19:47:56 +02:00
Aiirondev_dev 60ec21f34e Implement lazy loading and prefetching for main admin items to enhance performance 2026-04-12 19:30:20 +02:00
Aiirondev_dev 57f6d2f8e7 Refactor MongoClient function to enhance configuration handling and preserve caller arguments 2026-04-12 19:18:30 +02:00
Aiirondev_dev 53575c1876 Refactor MongoDB client initialization for improved performance and simplicity 2026-04-12 17:50:24 +02:00
Aiirondev_dev 0f372f5056 Refactor MongoDB client imports and implement lazy loading for main items in UI 2026-04-12 17:36:49 +02:00
Aiirondev_dev c078de0076 Enhance library items API with pagination support and improve loading mechanism in UI 2026-04-12 17:22:49 +02:00
Aiirondev_dev daccf3334b Refactor bulk delete drawer for improved layout and accessibility 2026-04-10 23:20:20 +02:00
Aiirondev_dev ef605e080b Refactor bulk delete drawer for improved UI and functionality 2026-04-10 23:14:27 +02:00
Aiirondev_dev 1755ec222d Enhance bulk delete drawer UI with improved styles and button text 2026-04-10 23:08:02 +02:00
Aiirondev_dev 06ab3e1f4d Implement bulk delete drawer functionality with UI enhancements 2026-04-10 22:38:24 +02:00
Aiirondev_dev a2c79c80bc Implement bulk delete functionality for item groups with UI enhancements 2026-04-10 22:32:07 +02:00
Aiirondev_dev 7ac66ae3ba Refactor code structure for improved readability and maintainability 2026-04-10 22:08:25 +02:00
Aiirondev_dev 444a1df1aa Add audit review export functionality in markdown and JSON formats 2026-04-10 21:22:32 +02:00
Aiirondev_dev 0581b5c5e9 Add standalone audit event logging for borrowing and returning actions 2026-04-10 20:51:01 +02:00
Aiirondev_dev 2faf284a0e Implement tamper-evident audit logging and invoice correction features
- Introduced a new audit logging system that creates a tamper-evident chain of events in MongoDB.
- Added functions for appending audit events, ensuring index integrity, and verifying the audit chain.
- Implemented soft-delete functionality for inventory and borrowing records to comply with GoBD regulations.
- Added UI components for displaying invoice corrections and audit dashboard, including mismatch reporting.
- Created a CLI utility for verifying the integrity of the audit chain.
- Enhanced invoice management by preventing overwrites and allowing correction entries with reasons and optional deltas.
2026-04-10 20:32:56 +02:00
Aiirondev_dev 025e03f9ce Enhance image handling in admin template: add dynamic input management and improve UI for existing images 2026-04-10 17:23:56 +02:00
Aiirondev_dev a96e103733 Add bulk selection functionality for filter options in admin templates 2026-04-10 17:13:09 +02:00
Aiirondev_dev b636d06908 Add function to ensure runtime config JSON is created and backup unexpected directories 2026-04-10 16:42:36 +02:00
Aiirondev_dev a85461711c Add config.json handling for Docker setup and update script 2026-04-10 16:35:49 +02:00
31 changed files with 2959 additions and 157 deletions
+2
View File
@@ -203,6 +203,7 @@ jobs:
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
@@ -225,6 +226,7 @@ jobs:
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
cp backup.sh release-bundle/backup.sh
cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh
cp init-admin.sh release-bundle/init-admin.sh
+1 -1
View File
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
WORKDIR /app/Web
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
+180
View File
@@ -0,0 +1,180 @@
# GoBD Hardening Change Report (2026-04-10)
## Scope
This change set implements core hardening measures for GoBD-oriented operation:
1. Soft-delete instead of hard-delete for inventory and borrowing records.
2. Tamper-evident audit log chain for critical accounting/inventory events.
3. Invoice immutability model with correction entries instead of overwrite.
## Implemented Changes
### 1) Tamper-evident audit chain
- New module: `Web/audit_log.py`
- Audit entries are chained by hash (`prev_hash` -> `entry_hash`) with monotonic `chain_index`.
- Canonical JSON serialization is used to make hashing deterministic.
Current fields per audit event:
- `event_type`
- `actor`
- `source`
- `ip`
- `payload`
- `timestamp`
- `created_at`
- `prev_hash`
- `entry_hash`
- `chain_index`
Integrated event writes in:
- Item soft-delete flow
- Invoice creation
- Invoice paid marking
- Invoice finalize+repair
- Invoice correction entry creation
### 1b) Audit operational controls (phase 2)
- Added index management in `Web/audit_log.py`:
- Unique index on `chain_index`
- Additional indexes on `created_at` and `event_type`
- Added chain verification function in `Web/audit_log.py`.
- Added CLI verification tool: `Web/verify_audit_chain.py`.
- Added admin verification endpoint:
- `GET /admin/audit/verify`
- Returns chain integrity result (`200` if valid, `409` on mismatch).
- Added lazy index provisioning helper invoked in admin borrowing views.
### 2) Soft-delete conversion
#### Inventory items
- Deletion endpoint now marks records logically deleted:
- `Deleted: true`
- `DeletedAt`
- `DeletedBy`
- `LastUpdated`
- `Verfuegbar: false`
- Item-linked borrow records are also logically deleted (`Status: deleted`) instead of physically removed.
- Image files are no longer physically deleted in this flow.
#### Borrow records
- `remove_ausleihung` changed to set `Status: deleted` + timestamps.
- Retrieval helpers now exclude deleted records by default.
#### Item reads
- Item helper queries now exclude `Deleted: true` records.
- Grouped item lookups and appointment queries also exclude deleted records.
- Code uniqueness checks ignore deleted records, allowing controlled code reuse.
### 3) Invoice immutability and correction flow
- Invoice creation now blocks overwrite if an invoice already exists for the borrow record.
- New lock marker on invoice creation/update path: `InvoiceLocked: true`.
- New correction endpoint:
- `POST /admin/borrowings/<borrow_id>/invoice/correction`
- Appends entries to `InvoiceCorrections`
- Does not mutate existing `InvoiceData` body
- Requires correction reason
- Supports optional amount delta
### 3b) UI integration for correction flow (phase 2)
- Added correction action forms in:
- `Web/templates/admin_borrowings.html`
- `Web/templates/library_borrowings_admin.html`
- Added correction count display (`invoice_corrections_count`) in both admin tables.
## Detailed File-Level Review
### `Web/audit_log.py`
- Introduces chain-based audit persistence.
- Uses last chain entry to calculate next `chain_index` and hash.
- Adds explicit index provisioning and full chain verification routine.
- Tradeoff: application-level sequencing is improved by unique index, but concurrent peak writes may still require retry logic around duplicate key conflicts.
### `Web/verify_audit_chain.py`
- New CLI operational tool for manual/cron verification.
- Returns non-zero exit code on chain mismatch.
### `Web/app.py`
- Added `_append_audit_event(...)` helper and integrated it at critical event boundaries.
- Inventory API routes now hide soft-deleted items.
- `delete_item` changed from destructive deletion to soft-delete semantics.
- Invoice route now rejects overwrite and requires correction route for changes.
- Added correction route with immutable invoice core.
- Added admin audit verification route and lazy audit index initialization helper.
Behavioral impact:
- Deleted items no longer disappear from DB; they are hidden from normal views.
- Existing UI actions for delete continue to work, but now preserve evidence.
- Invoice re-creation attempts now return warning and redirect.
### `Web/items.py`
- Introduced `_active_record_query(...)` and applied it across item retrieval APIs.
- Converted `remove_item` to soft-delete update.
- Updated maintenance reset (`unstuck_item`) to status updates instead of `delete_many`.
Behavioral impact:
- Item-level DB history is preserved.
- Legacy scripts relying on hard delete semantics may need adaptation.
### `Web/ausleihung.py`
- Converted `remove_ausleihung` to soft-delete by status.
- Default retrieval paths now exclude deleted records.
Behavioral impact:
- Borrowing history remains in DB for traceability.
## Validation Performed
- Static diagnostics reported no errors in modified files:
- `Web/app.py`
- `Web/items.py`
- `Web/ausleihung.py`
- `Web/audit_log.py`
- Additional phase-2 checks:
- `python3 -m py_compile Web/app.py Web/audit_log.py Web/verify_audit_chain.py`
- No syntax errors
- Template diagnostics:
- `Web/templates/admin_borrowings.html` no errors
- `Web/templates/library_borrowings_admin.html` no errors
## Residual Risks / Open Points
1. Concurrency hardening for audit chain:
- Current chain append may produce collisions under parallel writes.
- Recommendation: transaction or optimistic retry with unique index on `chain_index`.
2. Broader query coverage:
- Some direct Mongo queries outside helper paths may still need explicit `Deleted != true` filters.
3. Formal GoBD process requirements beyond code:
- Verfahrensdokumentation must be updated.
- WORM/immutable storage for exported archives should be enforced externally.
- Operational controls (RBAC review, periodic reconciliation, restore drills) should be documented.
## Recommended Next Steps
1. Add retry strategy for audit write conflicts:
- Retry `append_audit_event` on duplicate `chain_index` key errors.
2. Add admin UI page for chain status and recent audit events:
- Human-readable inspection view on top of `/admin/audit/verify`.
3. Add policy enforcement tests:
- Ensure invoice overwrite is blocked.
- Ensure soft-deleted records are hidden in APIs.
- Ensure deletion endpoints never call physical delete operators.
4. Infrastructure-level immutability:
- Push periodic audit snapshots and invoice archives to immutable/WORM storage.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1253 -50
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
"""
Tamper-evident audit logging helpers.
The audit chain stores each entry with a hash of the previous entry.
Any mutation in history breaks the chain verification.
"""
import datetime
import hashlib
import json
import random
import time
from pymongo.errors import DuplicateKeyError
def _stable_json(value):
"""Serialize dictionaries in a stable way for deterministic hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
def _entry_hash(prev_hash, payload):
"""Build the chained entry hash from previous hash + canonical 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):
"""
Append an audit event to a tamper-evident chain.
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.
Returns:
dict: Inserted audit entry.
"""
logs = db["audit_log"]
attempts = 0
while attempts <= max_retries:
previous = logs.find_one(sort=[("chain_index", -1)])
prev_hash = previous.get("entry_hash", "") if previous else ""
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
timestamp = datetime.datetime.utcnow()
entry_payload = {
"event_type": event_type,
"actor": actor or "system",
"source": source,
"ip": request_ip or "",
"payload": payload or {},
"timestamp": timestamp.isoformat() + "Z",
}
entry_hash = _entry_hash(prev_hash, entry_payload)
entry = {
**entry_payload,
"created_at": timestamp,
"prev_hash": prev_hash,
"entry_hash": entry_hash,
"chain_index": chain_index,
}
try:
logs.insert_one(entry)
return entry
except DuplicateKeyError:
attempts += 1
if attempts > max_retries:
raise
# Exponential backoff with jitter to avoid retry storms.
delay = min(0.25, (0.005 * (2 ** attempts)) + random.random() * 0.01)
time.sleep(delay)
def ensure_audit_indexes(db):
"""Create indexes required for fast and safe audit operations."""
logs = db["audit_log"]
logs.create_index("chain_index", unique=True, name="audit_chain_index_unique")
logs.create_index("created_at", name="audit_created_at_idx")
logs.create_index("event_type", name="audit_event_type_idx")
def verify_audit_chain(db):
"""Verify hash chain integrity across all stored audit entries."""
logs = db["audit_log"]
entries = list(logs.find({}, {"_id": 1, "event_type": 1, "actor": 1, "source": 1, "ip": 1, "payload": 1, "timestamp": 1, "prev_hash": 1, "entry_hash": 1, "chain_index": 1}).sort("chain_index", 1))
previous_hash = ""
previous_index = 0
mismatches = []
for entry in entries:
chain_index = int(entry.get("chain_index", 0))
prev_hash = entry.get("prev_hash", "")
entry_hash = entry.get("entry_hash", "")
payload = {
"event_type": entry.get("event_type", ""),
"actor": entry.get("actor", ""),
"source": entry.get("source", ""),
"ip": entry.get("ip", ""),
"payload": entry.get("payload", {}),
"timestamp": entry.get("timestamp", ""),
}
expected_hash = _entry_hash(previous_hash, payload)
if chain_index != previous_index + 1:
mismatches.append({
"chain_index": chain_index,
"error": "chain_index_gap",
"expected": previous_index + 1,
"found": chain_index,
})
if prev_hash != previous_hash:
mismatches.append({
"chain_index": chain_index,
"error": "prev_hash_mismatch",
"expected": previous_hash,
"found": prev_hash,
})
if entry_hash != expected_hash:
mismatches.append({
"chain_index": chain_index,
"error": "entry_hash_mismatch",
"expected": expected_hash,
"found": entry_hash,
})
previous_hash = entry_hash
previous_index = chain_index
return {
"ok": len(mismatches) == 0,
"count": len(entries),
"last_chain_index": previous_index,
"last_hash": previous_hash,
"mismatches": mismatches,
}
+20 -12
View File
@@ -25,7 +25,6 @@ Sammlungsstruktur:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import pytz
@@ -34,6 +33,7 @@ import os
import json
import shutil
import settings as cfg
from settings import MongoClient
# Add this helper function after imports
def ensure_timezone_aware(dt):
@@ -365,8 +365,7 @@ def cancel_ausleihung(id):
def remove_ausleihung(id):
"""
Entfernt einen Ausleihungsdatensatz aus der Datenbank.
Hinweis: Normalerweise ist es besser, Datensätze zu markieren als sie zu löschen.
Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete).
Args:
id (str): ID des zu entfernenden Ausleihungsdatensatzes
@@ -378,9 +377,17 @@ def remove_ausleihung(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
result = ausleihungen.delete_one({'_id': ObjectId(id)})
now = datetime.datetime.now()
result = ausleihungen.update_one(
{'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}},
{'$set': {
'Status': 'deleted',
'DeletedAt': now,
'LastUpdated': now,
}}
)
client.close()
return result.deleted_count > 0
return result.modified_count > 0
except Exception as e:
# print(f"Error removing ausleihung: {e}") # Log the error
return False
@@ -429,14 +436,15 @@ def get_ausleihungen(status=None, start=None, end=None, date_filter='overlap'):
collection = db['ausleihungen']
# Query erstellen
query = {}
query = {'Status': {'$ne': 'deleted'}}
# Status-Filter hinzufügen
if status is not None:
if isinstance(status, list):
query['Status'] = {'$in': status}
allowed_status = [s for s in status if s != 'deleted']
query['Status'] = {'$in': allowed_status}
else:
query['Status'] = status
query['Status'] = status if status != 'deleted' else '__blocked_deleted_status__'
# Datum parsen, wenn als String angegeben
if start is not None and isinstance(start, str):
@@ -561,7 +569,7 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
query = {'User': user_id}
query = {'User': user_id, 'Status': {'$ne': 'deleted'}}
# Wenn clientseitige Verifikation verwendet wird, holen wir ALLE Ausleihungen
# und filtern später clientseitig
@@ -610,12 +618,12 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
# Status-Matching
if isinstance(status, list):
if current_status in status:
if current_status in status and current_status != 'deleted':
# Status aktualisieren und zur Ergebnismenge hinzufügen
ausleihung['VerifiedStatus'] = current_status
filtered_results.append(ausleihung)
else:
if current_status == status:
if current_status == status and current_status != 'deleted':
# Status aktualisieren und zur Ergebnismenge hinzufügen
ausleihung['VerifiedStatus'] = current_status
filtered_results.append(ausleihung)
@@ -644,7 +652,7 @@ def get_ausleihung_by_item(item_id, status=None, include_history=False):
ausleihungen = db['ausleihungen']
# Build query
query = {'Item': item_id}
query = {'Item': item_id, 'Status': {'$ne': 'deleted'}}
if status and not include_history:
query['Status'] = status
+46 -23
View File
@@ -26,10 +26,10 @@ Collection Structure:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import settings as cfg
from settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
@@ -43,6 +43,14 @@ def _non_library_query(extra_query=None):
return base_query
def _active_record_query(extra_query=None):
"""Build a query that excludes logically deleted records."""
base_query = {'Deleted': {'$ne': True}}
if extra_query:
base_query.update(extra_query)
return base_query
# === ITEM MANAGEMENT ===
def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, filter3=None,
@@ -118,7 +126,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
def remove_item(id):
"""
Remove an item from the inventory.
Soft-delete an item from the inventory.
Args:
id (str): ID of the item to remove
@@ -130,9 +138,17 @@ def remove_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
result = items.delete_one({'_id': ObjectId(id)})
result = items.update_one(
{'_id': ObjectId(id), 'Deleted': {'$ne': True}},
{'$set': {
'Deleted': True,
'DeletedAt': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now(),
'Verfuegbar': False,
}}
)
client.close()
return result.deleted_count > 0
return result.modified_count > 0
except Exception as e:
print(f"Error removing item: {e}")
return False
@@ -155,7 +171,7 @@ def get_group_item_ids(id):
db = client[cfg.MONGODB_DB]
items = db['items']
base_item = items.find_one({'_id': ObjectId(id)})
base_item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
if not base_item:
client.close()
return []
@@ -165,7 +181,7 @@ def get_group_item_ids(id):
# Prefer SeriesGroupId because it represents the full logical group.
series_group_id = base_item.get('SeriesGroupId')
if series_group_id:
for group_item in items.find({'SeriesGroupId': series_group_id}, {'_id': 1}):
for group_item in items.find(_active_record_query({'SeriesGroupId': series_group_id}), {'_id': 1}):
resolved_ids.add(str(group_item['_id']))
else:
resolved_ids.add(str(base_item['_id']))
@@ -173,10 +189,10 @@ def get_group_item_ids(id):
parent_item_id = base_item.get('ParentItemId')
if parent_item_id:
resolved_ids.add(str(parent_item_id))
for sibling in items.find({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}, {'_id': 1}):
for sibling in items.find(_active_record_query({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}), {'_id': 1}):
resolved_ids.add(str(sibling['_id']))
else:
for child in items.find({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}, {'_id': 1}):
for child in items.find(_active_record_query({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}), {'_id': 1}):
resolved_ids.add(str(child['_id']))
client.close()
@@ -342,7 +358,7 @@ def is_code_unique(code_4, exclude_id=None):
items = db['items']
# Build query to find items with this code
query = {'Code_4': code_4}
query = {'Code_4': code_4, 'Deleted': {'$ne': True}}
# If we're editing an item, exclude it from the uniqueness check
if exclude_id:
@@ -368,7 +384,7 @@ def get_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find(_non_library_query())
items_return = items.find(_active_record_query(_non_library_query()))
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
@@ -391,7 +407,7 @@ def get_available_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find(_non_library_query({'Verfuegbar': True}))
items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': True})))
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
@@ -414,7 +430,7 @@ def get_borrowed_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find(_non_library_query({'Verfuegbar': False}))
items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': False})))
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
@@ -440,7 +456,7 @@ def get_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
item = items.find_one({'_id': ObjectId(id)})
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
return item
except Exception as e:
@@ -462,7 +478,7 @@ def get_item_by_name(name):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
item = items.find_one({'Name': name})
item = items.find_one(_active_record_query({'Name': name}))
client.close()
return item
except Exception as e:
@@ -486,13 +502,13 @@ def get_items_by_filter(filter_value):
items = db['items']
# Use $or to find matches in any filter field
query = _non_library_query({
query = _active_record_query(_non_library_query({
'$or': [
{'Filter': filter_value},
{'Filter2': filter_value},
{'Filter3': filter_value}
]
})
}))
results = list(items.find(query))
client.close()
@@ -518,7 +534,7 @@ def get_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
non_library = _non_library_query()
non_library = _active_record_query(_non_library_query())
filters = items.distinct('Filter', non_library)
filters2 = items.distinct('Filter2', non_library)
filters3 = items.distinct('Filter3', non_library)
@@ -550,7 +566,7 @@ def get_primary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
filters = [f for f in items.distinct('Filter', _non_library_query()) if f]
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close()
return filters
except Exception as e:
@@ -569,7 +585,7 @@ def get_secondary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
filters = [f for f in items.distinct('Filter2', _non_library_query()) if f]
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close()
return filters
except Exception as e:
@@ -588,7 +604,7 @@ def get_tertiary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
filters = [f for f in items.distinct('Filter3', _non_library_query()) if f]
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close()
return filters
except Exception as e:
@@ -610,7 +626,7 @@ def get_item_by_code_4(code_4):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
results = list(items.find(_non_library_query({"Code_4": code_4})))
results = list(items.find(_active_record_query(_non_library_query({"Code_4": code_4}))))
# Convert ObjectId to string
for item in results:
@@ -640,7 +656,14 @@ def unstuck_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
result = ausleihungen.delete_many({'Item': id})
result = ausleihungen.update_many(
{'Item': id, 'Status': {'$nin': ['cancelled', 'deleted']}},
{'$set': {
'Status': 'cancelled',
'CancelledReason': 'unstuck_reset',
'LastUpdated': datetime.datetime.now()
}}
)
# Also reset the item status
items = db['items']
@@ -980,7 +1003,7 @@ def get_items_with_appointments():
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find({'NextAppointment': {'$exists': True}})
items_return = items.find({'NextAppointment': {'$exists': True}, 'Deleted': {'$ne': True}})
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
+2 -1
View File
@@ -10,4 +10,5 @@ python-dateutil
pytz
requests
reportlab
python-barcode
python-barcode
openpyxl
+19
View File
@@ -12,6 +12,7 @@ defaults for the web application and helper modules.
"""
import os
import json
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -173,3 +174,21 @@ if not os.path.isabs(BACKUP_FOLDER):
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
if not os.path.isabs(LOGS_FOLDER):
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
def MongoClient(*args, **kwargs):
"""Return a lightweight MongoDB client configured from this settings module."""
client_kwargs = {
'maxPoolSize': 10,
'minPoolSize': 0,
'connectTimeoutMS': 5000,
'serverSelectionTimeoutMS': 5000,
'retryWrites': True,
}
client_kwargs.update(kwargs)
# Preserve caller-provided positional host/port arguments.
# If none are passed, use configured defaults.
if args:
return _PyMongoClient(*args, **client_kwargs)
return _PyMongoClient(MONGODB_HOST, MONGODB_PORT, **client_kwargs)
+94
View File
@@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="container" style="max-width:1400px; margin:0 auto; padding:18px;">
<h1>Audit Dashboard</h1>
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 18px;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export', format='md') }}">Audit Review exportieren (Markdown)</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}">Audit Review exportieren (JSON)</a>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
{% if verify_result.ok %}
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
{% else %}
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
{% endif %}
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Einträge</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Mismatch Count</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
</div>
</div>
{% if verify_result.mismatches %}
<div style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;">
<h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3>
<div style="max-height:280px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Typ</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Expected</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Found</th>
</tr>
</thead>
<tbody>
{% for m in verify_result.mismatches %}
<tr>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.chain_index }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.error }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.expected }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.found }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div style="border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:12px;">
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
<div style="max-height:560px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Zeit</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Event</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Actor</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Hash</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Payload</th>
</tr>
</thead>
<tbody>
{% for row in audit_rows %}
<tr>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.chain_index }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.timestamp or row.created_at }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.event_type }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.actor }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace; font-size:0.8rem;">{{ row.entry_hash }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;"><pre style="margin:0; white-space:pre-wrap; font-size:0.8rem;">{{ row.payload }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+10
View File
@@ -100,6 +100,9 @@
{% if e.invoice_number %}
<div style="font-weight:600;">{{ e.invoice_number }}</div>
<div style="font-size:0.85rem; color:#666;">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %}
<div style="font-size:0.78rem; color:#7c2d12; margin-top:4px;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %}
{% if e.invoice_paid %}
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Bezahlt</div>
{% if e.invoice_paid_at %}
@@ -129,6 +132,13 @@
</button>
</form>
{% endif %}
{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');">
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px;">
<input type="text" name="amount_delta" placeholder="Delta (optional)" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger">Korrektur</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning">Zurücksetzen</button>
</form>
+1
View File
@@ -418,6 +418,7 @@
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
@@ -282,6 +282,9 @@
{% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %}
<div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div>
@@ -326,6 +329,14 @@
</form>
{% endif %}
{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');">
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:140px;">
<input type="text" name="amount_delta" placeholder="Delta optional" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button>
</form>
{% endif %}
{% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning btn-sm">Zurücksetzen</button>
+164 -34
View File
@@ -290,6 +290,20 @@
color: #666;
}
.library-load-row {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.library-load-hint {
font-size: 0.9em;
color: #6b7280;
}
/* Modal styles */
.modal {
display: none;
@@ -501,6 +515,10 @@
<!-- Items will be loaded here -->
</tbody>
</table>
<div id="loadMoreRow" class="library-load-row" style="display:none;">
<span id="loadInfo" class="library-load-hint"></span>
<button id="loadMoreBtn" class="button" type="button">Mehr laden</button>
</div>
</div>
<!-- Empty state -->
@@ -523,6 +541,24 @@
// State
let libraryItems = [];
let filteredItems = [];
let visibleItems = [];
let currentSearchTerm = '';
let activeFilters = {
author: '',
isbn: '',
type: '',
status: ''
};
let pagingState = {
offset: 0,
total: 0,
hasMore: true,
loading: false
};
const API_PAGE_SIZE = 120;
const INITIAL_RENDER_COUNT = 120;
const RENDER_BATCH_COUNT = 120;
let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false;
let scannerInstance = null;
let scannerRunning = false;
@@ -531,32 +567,124 @@
let lastScanAt = 0;
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
// Load items
async function fetchLibraryPage(offset, limit) {
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to load library items');
}
return response.json();
}
// Load items progressively: first page immediately, rest in background
async function loadLibraryItems() {
if (pagingState.loading) return;
pagingState.loading = true;
try {
const response = await fetch('/api/library_items');
if (!response.ok) throw new Error('Failed to load library items');
libraryItems = await response.json();
displayItems(libraryItems);
const firstPage = await fetchLibraryPage(0, API_PAGE_SIZE);
libraryItems = firstPage.items || [];
pagingState.offset = (firstPage.offset || 0) + (firstPage.count || libraryItems.length);
pagingState.total = firstPage.total || libraryItems.length;
pagingState.hasMore = Boolean(firstPage.has_more);
applyFiltersAndSearch(true);
if (pagingState.hasMore) {
loadRemainingLibraryItemsInBackground();
}
} catch (error) {
console.error('Error loading library items:', error);
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
} finally {
pagingState.loading = false;
}
}
// Display items in table
function displayItems(items) {
async function loadRemainingLibraryItemsInBackground() {
while (pagingState.hasMore) {
try {
const page = await fetchLibraryPage(pagingState.offset, API_PAGE_SIZE);
const nextItems = page.items || [];
if (nextItems.length === 0) {
pagingState.hasMore = false;
break;
}
libraryItems = libraryItems.concat(nextItems);
pagingState.offset = (page.offset || pagingState.offset) + (page.count || nextItems.length);
pagingState.total = page.total || pagingState.total;
pagingState.hasMore = Boolean(page.has_more);
// Keep the current view reactive while avoiding a full rerender burst.
applyFiltersAndSearch(false);
if ('requestIdleCallback' in window) {
await new Promise(resolve => requestIdleCallback(() => resolve(), { timeout: 250 }));
} else {
await new Promise(resolve => setTimeout(resolve, 0));
}
} catch (error) {
console.error('Error loading additional library items:', error);
break;
}
}
}
function filterItems(items) {
const author = activeFilters.author;
const isbn = activeFilters.isbn;
const type = activeFilters.type;
const status = activeFilters.status;
return items.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch;
});
}
function applySearch(items) {
if (!currentSearchTerm) return items;
return items.filter(item =>
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
);
}
function applyFiltersAndSearch(resetRenderCount) {
const filteredByPanel = filterItems(libraryItems);
filteredItems = applySearch(filteredByPanel);
if (resetRenderCount) {
renderedCount = Math.min(INITIAL_RENDER_COUNT, filteredItems.length);
} else {
renderedCount = Math.min(Math.max(renderedCount, INITIAL_RENDER_COUNT), filteredItems.length || INITIAL_RENDER_COUNT);
}
visibleItems = filteredItems;
renderItems();
}
// Display current view with incremental rendering
function renderItems() {
const tbody = document.getElementById('itemsTableBody');
const emptyState = document.getElementById('emptyState');
const loadMoreRow = document.getElementById('loadMoreRow');
const loadInfo = document.getElementById('loadInfo');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (items.length === 0) {
if (visibleItems.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
loadMoreRow.style.display = 'none';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = items.map(item => `
const rowsToRender = visibleItems.slice(0, renderedCount);
tbody.innerHTML = rowsToRender.map(item => `
${(() => {
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
@@ -567,7 +695,7 @@
<tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code4 || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td>
<span class="table-status ${statusClass}">
@@ -585,6 +713,18 @@
`;
})()}
`).join('');
const canLoadMoreRows = renderedCount < visibleItems.length;
if (canLoadMoreRows) {
loadMoreRow.style.display = 'flex';
loadMoreBtn.style.display = 'inline-block';
loadInfo.textContent = `${renderedCount} von ${visibleItems.length} angezeigt`;
} else {
const backgroundHint = pagingState.hasMore ? `Lade weitere Medien im Hintergrund (${libraryItems.length}/${pagingState.total || '?'})...` : `${visibleItems.length} Treffer`;
loadMoreRow.style.display = 'flex';
loadMoreBtn.style.display = 'none';
loadInfo.textContent = backgroundHint;
}
}
// Filter toggle
@@ -598,21 +738,11 @@
// Apply filters
document.getElementById('applyFilterBtn').addEventListener('click', () => {
const author = document.getElementById('filterAuthor').value.toLowerCase();
const isbn = document.getElementById('filterISBN').value.toLowerCase();
const type = document.getElementById('filterType').value;
const status = document.getElementById('filterStatus').value;
filteredItems = libraryItems.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch;
});
displayItems(filteredItems);
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
activeFilters.type = document.getElementById('filterType').value;
activeFilters.status = document.getElementById('filterStatus').value;
applyFiltersAndSearch(true);
});
// Clear filters
@@ -621,19 +751,19 @@
document.getElementById('filterISBN').value = '';
document.getElementById('filterType').value = '';
document.getElementById('filterStatus').value = '';
displayItems(libraryItems);
activeFilters = { author: '', isbn: '', type: '', status: '' };
applyFiltersAndSearch(true);
});
// Search input
document.getElementById('librarySearch').addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const toSearch = filteredItems.length > 0 ? filteredItems : libraryItems;
const results = toSearch.filter(item =>
(item.Name || '').toLowerCase().includes(searchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(searchTerm) ||
(item.ISBN || item.Code4 || '').toLowerCase().includes(searchTerm)
);
displayItems(results);
currentSearchTerm = (e.target.value || '').toLowerCase();
applyFiltersAndSearch(true);
});
document.getElementById('loadMoreBtn').addEventListener('click', () => {
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
renderItems();
});
// Helper functions
+276 -9
View File
@@ -724,8 +724,75 @@
// Add this line to define allItems globally
let allItems = [];
function loadItems() {
fetch("{{ url_for('get_items') }}")
const MAIN_ITEMS_PAGE_SIZE = 120;
let mainItemsNextOffset = 0;
let mainItemsHasMore = false;
let mainItemsLoadingMore = false;
let mainItemsLightMode = true; // Track if we're in light mode to gradually request full data
let mainItemsObserver = null;
let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false;
function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator';
mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainItemsLoadingIndicator);
return itemsContainer;
}
function updateMainItemsLoadingIndicator() {
const itemsContainer = ensureMainItemsLoadingIndicator();
if (!itemsContainer || !mainItemsLoadingIndicator) return;
const shouldShow = mainItemsHasMore || mainItemsLoadingMore;
mainItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainItemsLoadingIndicator.classList.toggle('is-loading', mainItemsLoadingMore);
const label = mainItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
function maybePrefetchMainItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainItemsHasMore || mainItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -735,27 +802,39 @@
const filter3Values = new Set();
// Store all items data globally for filter rebuilding
allItems = data.items || [];
const pageItems = data.items || [];
allItems = append ? allItems.concat(pageItems) : pageItems;
// Clear the container first
itemsContainer.innerHTML = '';
if (!append) {
itemsContainer.innerHTML = '';
}
if (!data.items || data.items.length === 0) {
if (!append && (!pageItems || pageItems.length === 0)) {
itemsContainer.innerHTML = '<div class="no-items-message">Keine Objekte gefunden</div>';
return;
}
// Sort items by name in ascending order (A-Z)
data.items.sort((a, b) => {
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = b.Name ? b.Name.toLowerCase() : '';
return nameA.localeCompare(nameB); // Ascending order
});
allItems.forEach(itemForFilters => {
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
});
const favoriteIds = new Set(data.favorites || []);
window.currentFavorites = favoriteIds;
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
data.items.forEach(item => {
pageItems.forEach(item => {
try {
const card = document.createElement('div');
card.classList.add('item-card');
@@ -995,8 +1074,10 @@
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Start display from leftmost position (first card, which is now Z)
itemsContainer.scrollLeft = 0;
// Start display from leftmost position on full reload only.
if (!append) {
itemsContainer.scrollLeft = 0;
}
// Apply filters
applyFilters();
@@ -1011,15 +1092,93 @@
if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) {
rebuildFilter3Options();
}
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainItemsHasMore = Boolean(data.has_more);
updateMainItemsLoadingIndicator();
setupMainItemsLazyLoading();
maybePrefetchMainItems();
})
.catch(error => {
console.error('Error fetching items:', error);
mainItemsLoadingMore = false;
mainItemsHasMore = false;
updateMainItemsLoadingIndicator();
if (append) {
return;
}
const itemsContainer = document.querySelector('#items-container');
itemsContainer.innerHTML =
'<div class="error-message">Fehler beim Laden der Objekte. Bitte versuchen Sie es später erneut.</div>';
});
}
function ensureMainItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainItemsLoadingIndicator();
if (!mainItemsSentinel) {
mainItemsSentinel = document.createElement('div');
mainItemsSentinel.id = 'main-items-sentinel';
mainItemsSentinel.style.flex = '0 0 1px';
mainItemsSentinel.style.width = '1px';
mainItemsSentinel.style.minWidth = '1px';
mainItemsSentinel.style.height = '1px';
mainItemsSentinel.style.pointerEvents = 'none';
}
if (!mainItemsSentinel.parentNode) {
itemsContainer.appendChild(mainItemsSentinel);
} else {
itemsContainer.appendChild(mainItemsSentinel);
}
return itemsContainer;
}
function setupMainItemsLazyLoading() {
const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return;
if (mainItemsObserver) {
mainItemsObserver.disconnect();
mainItemsObserver = null;
}
if (!mainItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
mainItemsScrollPrefetchBound = true;
}
if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainItemsLoadingMore || !mainItemsHasMore) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
updateMainItemsLoadingIndicator();
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
@@ -1923,6 +2082,42 @@
applyFilters();
}
function bulkToggleFilterOptions(filterNum, shouldSelect) {
const filterKey = `filter${filterNum}`;
const checkboxes = Array.from(document.querySelectorAll(`#filter${filterNum}-options input[type="checkbox"]`));
if (checkboxes.length === 0) {
return;
}
const nextValues = new Set(activeFilters[filterKey]);
checkboxes.forEach(checkbox => {
const value = checkbox.value;
const group = checkbox.dataset.group || '';
const filterValue = group ? `${group}:${value}` : value;
checkbox.checked = shouldSelect;
if (shouldSelect) {
nextValues.add(filterValue);
} else {
nextValues.delete(filterValue);
}
});
activeFilters[filterKey] = Array.from(nextValues);
updateSelectedFilters(filterNum);
if (filterNum === 1) {
rebuildFilter2Options();
rebuildFilter3Options();
} else if (filterNum === 2) {
rebuildFilter3Options();
}
saveFilterState();
applyFilters();
}
function populateFilterOptions(containerId, filterValues, filterNum) {
const container = document.getElementById(containerId);
if (!container) {
@@ -1932,6 +2127,27 @@
container.innerHTML = ''; // Clear previous options
const bulkActions = document.createElement('div');
bulkActions.style.display = 'flex';
bulkActions.style.gap = '8px';
bulkActions.style.marginBottom = '10px';
const selectAllBtn = document.createElement('button');
selectAllBtn.type = 'button';
selectAllBtn.className = 'clear-filter';
selectAllBtn.textContent = 'Alle wählen';
selectAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, true);
const clearAllBtn = document.createElement('button');
clearAllBtn.type = 'button';
clearAllBtn.className = 'clear-filter';
clearAllBtn.textContent = 'Alle abwählen';
clearAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, false);
bulkActions.appendChild(selectAllBtn);
bulkActions.appendChild(clearAllBtn);
container.appendChild(bulkActions);
// First group values by category/prefix if they exist
const groupedValues = {};
@@ -2680,6 +2896,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
+622 -25
View File
@@ -26,6 +26,106 @@
font-weight: 600;
}
.bulk-delete-inline-wrap {
display: inline-flex;
}
.bulk-delete-drawer-toggle {
position: static;
transform: none;
z-index: 2;
}
.bulk-delete-drawer-toggle .drawer-icon {
display: inline-block;
margin-right: 4px;
}
.bulk-delete-drawer-toggle:hover {
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
}
.bulk-delete-drawer-toggle:active {
transform: scale(0.98);
}
.bulk-delete-drawer {
display: none;
width: 100%;
max-width: 980px;
margin: 0 auto 18px;
border: 1px solid #dbe3ee;
background: #ffffff;
border-radius: 10px;
padding: 14px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.bulk-delete-drawer.open {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 14px;
}
.bulk-delete-content {
flex: 1 1 380px;
}
.bulk-delete-drawer strong {
display: block;
margin-bottom: 2px;
}
.bulk-delete-summary {
color: #334155;
font-size: 0.92rem;
margin-top: 4px;
}
.bulk-delete-drawer small {
color: #64748b;
display: block;
margin-top: 2px;
line-height: 1.35;
}
.bulk-delete-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
align-items: center;
flex: 0 1 auto;
}
.bulk-delete-actions button {
border: 1px solid #dbe3ee;
border-radius: 8px;
padding: 9px 12px;
cursor: pointer;
background: #f8fafc;
color: #1e293b;
font-weight: 600;
text-align: center;
}
.bulk-delete-actions button:hover {
background: #eef4ff;
border-color: #bfdbfe;
}
.bulk-delete-actions .danger {
background: #dc3545;
color: #fff;
border-color: #c82333;
}
.bulk-delete-actions .danger:hover {
background: #c82333;
border-color: #b21f2d;
}
/* Filter styles */
.filter-container {
display: flex;
@@ -223,6 +323,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
@@ -237,6 +388,12 @@
position: relative;
}
.item-card.bulk-selected {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.18), 0 0 10px rgba(0, 0, 0, 0.1);
background: #fffdfd;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
@@ -257,6 +414,45 @@
margin: 10px 0;
}
.bulk-delete-row {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.bulk-delete-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
color: #475569;
cursor: pointer;
user-select: none;
}
.bulk-delete-toggle input {
width: 16px;
height: 16px;
accent-color: #dc3545;
cursor: pointer;
}
@media (max-width: 768px) {
.bulk-delete-drawer.open {
flex-direction: column;
}
.bulk-delete-actions {
width: 100%;
}
.bulk-delete-actions button {
width: 100%;
}
}
.item-card .item-image {
width: 100%;
height: auto;
@@ -2109,9 +2305,26 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button id="toggle-favorites-view" class="view-toggle-btn" title="Nur Merkliste anzeigen">
<span id="favorites-view-icon">🔖</span>
</button>
<div class="bulk-delete-inline-wrap">
<button type="button" id="bulk-delete-drawer-toggle" class="view-toggle-btn bulk-delete-drawer-toggle" aria-expanded="false" title="Massenlöschung" onclick="toggleBulkDeleteDrawer()">
<span class="drawer-icon"></span>
</button>
</div>
</div>
</h1>
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div>
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
<div class="bulk-delete-content">
<strong>Massenlöschung nach Kriterien</strong>
<div class="bulk-delete-summary" id="bulk-delete-summary">0 Elemente ausgewählt</div>
<small>Filter zuerst setzen, dann alle sichtbaren Elemente markieren oder einzelne Karten per Checkbox auswählen.</small>
</div>
<div class="bulk-delete-actions">
<button type="button" onclick="selectVisibleItems()">Alle sichtbaren auswählen</button>
<button type="button" onclick="clearBulkSelection()">Auswahl leeren</button>
<button type="button" id="bulk-delete-button" class="danger" onclick="deleteSelectedItems()" disabled>Auswahl löschen</button>
</div>
</div>
<div class="filter-container">
<div class="filter-group">
<div class="filter-header">
@@ -2492,6 +2705,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = '';
let allItems = []; // Cache for all items
let bulkDeleteSelection = new Set();
let bulkDeleteDrawerOpen = false;
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
const studentDefaultBorrowDays = {{ student_default_borrow_days|default(14) }};
const studentMaxBorrowDays = {{ student_max_borrow_days|default(365) }};
@@ -2802,9 +3017,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (parsedFilters.filter3) activeFilters.filter3 = parsedFilters.filter3;
// Update UI to show selected filters
updateSelectedFilters(1);
updateSelectedFilters(2);
updateSelectedFilters(3);
updateSelectedFiltersDisplay(1);
updateSelectedFiltersDisplay(2);
updateSelectedFiltersDisplay(3);
// We'll rebuild Filter 3 options after items are loaded
} catch (e) {
@@ -2822,6 +3037,134 @@ document.addEventListener('DOMContentLoaded', ()=>{
filter3: []
};
}
function updateBulkDeleteSummary() {
const summary = document.getElementById('bulk-delete-summary');
const button = document.getElementById('bulk-delete-button');
const count = bulkDeleteSelection.size;
if (summary) {
summary.textContent = `${count} ${count === 1 ? 'Element' : 'Elemente'} ausgewählt`;
}
if (button) {
button.disabled = count === 0;
button.textContent = count === 0 ? 'Auswahl löschen' : `Auswahl löschen (${count})`;
}
}
function setBulkDeleteDrawer(open) {
bulkDeleteDrawerOpen = Boolean(open);
const drawer = document.getElementById('bulk-delete-drawer');
const toggle = document.getElementById('bulk-delete-drawer-toggle');
if (drawer) {
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
}
if (toggle) {
toggle.setAttribute('aria-expanded', bulkDeleteDrawerOpen ? 'true' : 'false');
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
}
}
function toggleBulkDeleteDrawer() {
setBulkDeleteDrawer(!bulkDeleteDrawerOpen);
}
function setBulkDeleteSelection(itemId, checked) {
const normalizedId = String(itemId || '').trim();
if (!normalizedId) {
return;
}
if (checked) {
bulkDeleteSelection.add(normalizedId);
} else {
bulkDeleteSelection.delete(normalizedId);
}
document.querySelectorAll(`.item-card[data-item-id='${normalizedId}']`).forEach(card => {
card.classList.toggle('bulk-selected', checked);
const checkbox = card.querySelector('.bulk-delete-checkbox');
if (checkbox && checkbox.checked !== checked) {
checkbox.checked = checked;
}
});
updateBulkDeleteSummary();
}
function selectVisibleItems() {
document.querySelectorAll('.item-card').forEach(card => {
if (card.style.display === 'none') {
return;
}
const itemId = String(card.dataset.itemId || '').trim();
if (!itemId) {
return;
}
bulkDeleteSelection.add(itemId);
card.classList.add('bulk-selected');
const checkbox = card.querySelector('.bulk-delete-checkbox');
if (checkbox) {
checkbox.checked = true;
}
});
updateBulkDeleteSummary();
}
function clearBulkSelection() {
bulkDeleteSelection.clear();
document.querySelectorAll('.item-card.bulk-selected').forEach(card => {
card.classList.remove('bulk-selected');
const checkbox = card.querySelector('.bulk-delete-checkbox');
if (checkbox) {
checkbox.checked = false;
}
});
updateBulkDeleteSummary();
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && bulkDeleteDrawerOpen) {
setBulkDeleteDrawer(false);
}
});
function deleteSelectedItems() {
const selectedIds = [...bulkDeleteSelection];
if (selectedIds.length === 0) {
alert('Bitte zuerst mindestens ein Element auswählen.');
return;
}
if (!confirm(`Wirklich ${selectedIds.length} ausgewählte Elemente revisionssicher deaktivieren?`)) {
return;
}
fetch('{{ url_for('bulk_delete_items') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ item_ids: selectedIds })
})
.then(async response => {
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.success) {
throw new Error(data.message || 'Fehler beim Sammellöschen.');
}
clearBulkSelection();
alert(data.message || 'Auswahl gelöscht.');
loadItems();
})
.catch(error => {
alert(error.message || 'Fehler beim Sammellöschen.');
});
}
// Load predefined filter values for dropdowns
function loadPredefinedFilterValues(filterNumber) {
@@ -2847,6 +3190,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
const allOption = document.createElement('option');
allOption.value = '__ALL__';
allOption.textContent = '-- Alle auswählen --';
select.appendChild(allOption);
// Add new options - data.values contains the array
data.values.forEach(filter => {
@@ -2958,8 +3306,76 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
// Function to load items from server
function loadItems() {
fetch("{{ url_for('get_items') }}")
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
let mainAdminItemsNextOffset = 0;
let mainAdminItemsHasMore = false;
let mainAdminItemsLoadingMore = false;
let mainAdminLightMode = true; // Track if we're in light mode to gradually request full data
let mainAdminItemsObserver = null;
let mainAdminItemsSentinel = null;
let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0;
function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator';
mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
return itemsContainer;
}
function updateMainAdminItemsLoadingIndicator() {
const itemsContainer = ensureMainAdminItemsLoadingIndicator();
if (!itemsContainer || !mainAdminItemsLoadingIndicator) return;
const shouldShow = mainAdminItemsHasMore || mainAdminItemsLoadingMore;
mainAdminItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainAdminItemsLoadingIndicator.classList.toggle('is-loading', mainAdminItemsLoadingMore);
const label = mainAdminItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainAdminItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
function maybePrefetchMainAdminItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainAdminItemsHasMore || mainAdminItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainAdminItemsLoadingMore = true;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -2969,9 +3385,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter1Values = new Set();
const filter2Values = new Set();
const filter3Values = new Set();
allItems = data.items || [];
itemsContainer.innerHTML = '';
if (!data.items || data.items.length === 0) {
const pageItems = data.items || [];
allItems = append ? allItems.concat(pageItems) : pageItems;
totalItemsInSystem = Number(data.total || totalItemsInSystem || pageItems.length || 0);
if (!append) {
itemsContainer.innerHTML = '';
}
if (!append && (!pageItems || pageItems.length === 0)) {
itemsContainer.innerHTML = `
<div class="no-items-message">
<div>Keine Objekte gefunden</div>
@@ -2984,15 +3404,25 @@ document.addEventListener('DOMContentLoaded', ()=>{
return;
}
if (itemsIndicator) {
itemsIndicator.textContent = `Objekte im System: ${data.items.length}`;
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem}${data.has_more ? ' (lädt...)' : ''}`;
}
data.items.sort((a, b) => {
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = a.Name ? a.Name.toLowerCase() : '';
return nameA.localeCompare(nameB);
});
allItems.forEach(itemForFilters => {
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
});
const favoriteIds = new Set(data.favorites || []);
data.items.forEach(item => {
pageItems.forEach(item => {
try {
const card = document.createElement('div');
card.classList.add('item-card');
@@ -3143,6 +3573,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
${appointmentBadge}
</div>
<div class="actions">
<div class="bulk-delete-row">
<label class="bulk-delete-toggle">
<input type="checkbox" class="bulk-delete-checkbox" data-item-id="${item._id}" ${bulkDeleteSelection.has(String(item._id)) ? 'checked' : ''} onchange="setBulkDeleteSelection('${item._id}', this.checked)">
Für Löschung markieren
</label>
</div>
${isAvailableForBorrow && !item.BlockedNow ?
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
${isGroupedItem ? `
@@ -3218,8 +3654,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
itemsContainer.scrollLeft = 0;
if (!append) {
itemsContainer.scrollLeft = 0;
}
applyFilters();
updateBulkDeleteSummary();
// Setup proper image display for all cards
setupCardImagesDisplay();
@@ -3232,9 +3671,21 @@ document.addEventListener('DOMContentLoaded', ()=>{
rebuildFilter3Options();
}
/* favorites render count removed */
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainAdminItemsHasMore = Boolean(data.has_more);
updateMainAdminItemsLoadingIndicator();
setupMainAdminItemsLazyLoading();
maybePrefetchMainAdminItems();
})
.catch(error => {
console.error('Error fetching items:', error);
mainAdminItemsLoadingMore = false;
mainAdminItemsHasMore = false;
updateMainAdminItemsLoadingIndicator();
if (append) {
return;
}
const itemsContainer = document.querySelector('#items-container');
const itemsIndicator = document.getElementById('items-indicator');
itemsContainer.innerHTML =
@@ -3245,6 +3696,67 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function ensureMainAdminItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainAdminItemsLoadingIndicator();
if (!mainAdminItemsSentinel) {
mainAdminItemsSentinel = document.createElement('div');
mainAdminItemsSentinel.id = 'main-admin-items-sentinel';
mainAdminItemsSentinel.style.flex = '0 0 1px';
mainAdminItemsSentinel.style.width = '1px';
mainAdminItemsSentinel.style.minWidth = '1px';
mainAdminItemsSentinel.style.height = '1px';
mainAdminItemsSentinel.style.pointerEvents = 'none';
}
itemsContainer.appendChild(mainAdminItemsSentinel);
return itemsContainer;
}
function setupMainAdminItemsLazyLoading() {
const itemsContainer = ensureMainAdminItemsSentinel();
if (!itemsContainer) return;
if (mainAdminItemsObserver) {
mainAdminItemsObserver.disconnect();
mainAdminItemsObserver = null;
}
if (!mainAdminItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
mainAdminItemsScrollPrefetchBound = true;
}
if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainAdminItemsLoadingMore || !mainAdminItemsHasMore) {
return;
}
mainAdminItemsLoadingMore = true;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
updateMainAdminItemsLoadingIndicator();
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
@@ -3339,7 +3851,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
if (itemsIndicator) {
itemsIndicator.textContent = `Objekte im System: ${allItems.length} | Angezeigt: ${visibleCount}`;
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem || allItems.length} | Angezeigt: ${visibleCount}`;
}
if (isCodeSearch && visibleCount > 0 && (codeMatchCount > 0 || exactMatchCount > 0)) {
@@ -3528,7 +4040,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Display existing images
const existingImagesContainer = document.getElementById('edit-existing-images');
const editForm = document.getElementById('edit-item-form');
existingImagesContainer.innerHTML = '';
if (editForm) {
editForm.querySelectorAll('input[name="existing_images"], input[name="removed_images"]').forEach(input => input.remove());
}
if (item.Images && Array.isArray(item.Images)) {
item.Images.forEach((image, index) => {
const isVideo = isVideoFile(image);
@@ -3543,22 +4059,46 @@ document.addEventListener('DOMContentLoaded', ()=>{
image :
`{{ url_for('uploaded_file', filename='') }}${image}`);
const row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '8px';
row.style.alignItems = 'center';
if (isVideo) {
imageDiv.innerHTML = `
<div style="display:flex; gap:8px; align-items:center;">
<video src="${imageSrc}" style="max-width:100px; max-height:100px; object-fit:contain;" controls></video>
<button type="button" class="delete-image-button" onclick="deleteExistingImage('${item._id}', ${index})">Löschen</button>
</div>
`;
const video = document.createElement('video');
video.src = imageSrc;
video.style.maxWidth = '100px';
video.style.maxHeight = '100px';
video.style.objectFit = 'contain';
video.controls = true;
row.appendChild(video);
} else {
imageDiv.innerHTML = `
<div style="display:flex; gap:8px; align-items:center;">
<img src="${imageSrc}" style="max-width:100px; max-height:100px; object-fit:contain;" alt="Existierendes Bild ${index + 1}">
<button type="button" class="delete-image-button" onclick="deleteExistingImage('${item._id}', ${index})">Löschen</button>
</div>
`;
const img = document.createElement('img');
img.src = imageSrc;
img.style.maxWidth = '100px';
img.style.maxHeight = '100px';
img.style.objectFit = 'contain';
img.alt = `Existierendes Bild ${index + 1}`;
row.appendChild(img);
}
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-image-button';
deleteButton.textContent = 'Löschen';
deleteButton.addEventListener('click', () => removeExistingImage(image, deleteButton));
row.appendChild(deleteButton);
imageDiv.appendChild(row);
existingImagesContainer.appendChild(imageDiv);
if (editForm) {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = image;
editForm.appendChild(hiddenInput);
}
});
}
@@ -4401,6 +4941,42 @@ document.addEventListener('DOMContentLoaded', ()=>{
applyFilters();
}
function bulkToggleFilterOptions(filterNum, shouldSelect) {
const filterKey = `filter${filterNum}`;
const checkboxes = Array.from(document.querySelectorAll(`#filter${filterNum}-options input[type="checkbox"]`));
if (checkboxes.length === 0) {
return;
}
const nextValues = new Set(activeFilters[filterKey]);
checkboxes.forEach(checkbox => {
const value = checkbox.value;
const group = checkbox.dataset.group || '';
const filterValue = group ? `${group}:${value}` : value;
checkbox.checked = shouldSelect;
if (shouldSelect) {
nextValues.add(filterValue);
} else {
nextValues.delete(filterValue);
}
});
activeFilters[filterKey] = Array.from(nextValues);
updateSelectedFiltersDisplay(filterNum);
if (filterNum === 1) {
rebuildFilter2Options();
rebuildFilter3Options();
} else if (filterNum === 2) {
rebuildFilter3Options();
}
saveFilterState();
applyFilters();
}
function populateFilterOptions(containerId, filterValues, filterNum) {
const container = document.getElementById(containerId);
if (!container) {
@@ -4408,6 +4984,27 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
container.innerHTML = '';
const bulkActions = document.createElement('div');
bulkActions.style.display = 'flex';
bulkActions.style.gap = '8px';
bulkActions.style.marginBottom = '10px';
const selectAllBtn = document.createElement('button');
selectAllBtn.type = 'button';
selectAllBtn.className = 'clear-filter';
selectAllBtn.textContent = 'Alle wählen';
selectAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, true);
const clearAllBtn = document.createElement('button');
clearAllBtn.type = 'button';
clearAllBtn.className = 'clear-filter';
clearAllBtn.textContent = 'Alle abwählen';
clearAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, false);
bulkActions.appendChild(selectAllBtn);
bulkActions.appendChild(clearAllBtn);
container.appendChild(bulkActions);
const groupedValues = {};
+30
View File
@@ -710,6 +710,28 @@
<div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
</form>
</div>
{% else %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form>
</div>
{% endif %}
<div class="upload-form">
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
@@ -1114,9 +1136,17 @@
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
const allOption = document.createElement('option');
allOption.value = '__ALL__';
allOption.textContent = '-- Alle auswählen --';
select.appendChild(allOption);
// Add new options - data.values contains the array
data.values.forEach(value => {
if (!value || String(value).trim() === '') {
return;
}
const option = document.createElement('option');
option.value = value;
option.textContent = value;
+1 -1
View File
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
import hashlib
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
def normalize_student_card_id(card_id):
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""CLI utility to verify the tamper-evident audit chain."""
import json
import sys
from pymongo import MongoClient
import settings as cfg
import audit_log as al
def main():
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0 if result.get("ok") else 2
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -42,6 +42,7 @@ services:
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
+2 -1
View File
@@ -9,4 +9,5 @@ python-dateutil
pytz
requests
reportlab
python-barcode
python-barcode
openpyxl
+39
View File
@@ -290,6 +290,44 @@ EOF
fi
}
ensure_runtime_config_json() {
local config_path backup_path
config_path="$SCRIPT_DIR/config.json"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
{
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 443,
"mongodb": {
"host": "mongodb",
"port": 27017,
"db": "Inventarsystem"
},
"modules": {
"library": {
"enabled": false
},
"student_cards": {
"enabled": false,
"default_borrow_days": 14,
"max_borrow_days": 365
}
}
}
EOF
echo "Created default runtime config at $config_path"
fi
}
ensure_app_image_loaded() {
if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
return 0
@@ -558,6 +596,7 @@ ensure_runtime_dependencies
setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json
setup_scheduled_jobs
configure_nuitka_mode
resolve_app_image
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -325,6 +325,11 @@ download_and_extract_bundle() {
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
fi
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
log_message "Installed default config.json from release bundle"
fi
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh"
}