Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d8213b8c2 | |||
| b94fb1cd97 | |||
| ce20f569a3 | |||
| ec7f32994c | |||
| 8358bf257f | |||
| 193495d6a2 | |||
| 5afcaa5e19 | |||
| 3aa44b64b8 | |||
| 86d8f19313 | |||
| e74798f252 | |||
| 31c3d700a8 | |||
| a3c114e4b5 | |||
| 170a2a53ab | |||
| a8ac08d5b0 | |||
| 05c4c73635 | |||
| 76dd00831e | |||
| 205a56ff24 | |||
| 60ec21f34e | |||
| 57f6d2f8e7 | |||
| 53575c1876 | |||
| 0f372f5056 | |||
| c078de0076 | |||
| daccf3334b | |||
| ef605e080b | |||
| 1755ec222d | |||
| 06ab3e1f4d | |||
| a2c79c80bc | |||
| 7ac66ae3ba | |||
| 444a1df1aa | |||
| 0581b5c5e9 | |||
| 2faf284a0e |
+1
-1
@@ -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", "-"]
|
||||
|
||||
@@ -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.
|
||||
+2
-1
@@ -6,9 +6,10 @@ The latest version will allways be supported the rest are old version that are n
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.2.17 | ✅ |
|
||||
| 3.2.x | :white_check_mark: |
|
||||
| 3.1.x | :x: |
|
||||
| 3.0.x | :white_check_mark: |
|
||||
| 3.0.x | :x: |
|
||||
| 2.6.x | :x: |
|
||||
| 2.4.x | :x: |
|
||||
| 1.8.x | :x: |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1798
-129
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Helpers for targeted PII encryption and encrypted archival of deleted media files."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
import settings as cfg
|
||||
|
||||
_ENC_PREFIX = "enc::"
|
||||
|
||||
|
||||
def _resolve_fernet_key():
|
||||
"""Resolve the Fernet key from env/config or derive a stable fallback."""
|
||||
configured_key = cfg.DATA_ENCRYPTION_KEY
|
||||
if configured_key:
|
||||
try:
|
||||
# Validate the supplied key format.
|
||||
Fernet(configured_key.encode("utf-8"))
|
||||
return configured_key.encode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback for compatibility: derive stable key from SECRET_KEY.
|
||||
digest = hashlib.sha256(cfg.SECRET_KEY.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def _fernet():
|
||||
return Fernet(_resolve_fernet_key())
|
||||
|
||||
|
||||
def encrypt_text(value):
|
||||
"""Encrypt a text value. Keeps empty values unchanged."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if text == "" or text.startswith(_ENC_PREFIX):
|
||||
return text
|
||||
token = _fernet().encrypt(text.encode("utf-8")).decode("utf-8")
|
||||
return f"{_ENC_PREFIX}{token}"
|
||||
|
||||
|
||||
def decrypt_text(value):
|
||||
"""Decrypt an encrypted text value. Returns original value if not encrypted."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if not text.startswith(_ENC_PREFIX):
|
||||
return text
|
||||
|
||||
token = text[len(_ENC_PREFIX):]
|
||||
try:
|
||||
return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
|
||||
except (InvalidToken, ValueError, TypeError):
|
||||
# Keep data readable even if key rotation or malformed data occurred.
|
||||
return text
|
||||
|
||||
|
||||
def encrypt_document_fields(document, fields):
|
||||
"""Encrypt selected fields of a document in-place and return it."""
|
||||
for field in fields:
|
||||
if field in document:
|
||||
document[field] = encrypt_text(document.get(field))
|
||||
return document
|
||||
|
||||
|
||||
def decrypt_document_fields(document, fields):
|
||||
"""Decrypt selected fields of a document in-place and return it."""
|
||||
for field in fields:
|
||||
if field in document:
|
||||
document[field] = decrypt_text(document.get(field))
|
||||
return document
|
||||
|
||||
|
||||
def _candidate_media_paths(filename):
|
||||
"""Return all possible filesystem paths for a stored media filename."""
|
||||
name_part, _ = os.path.splitext(filename)
|
||||
return [
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, filename), "originals"),
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.webp"), "originals"),
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.jpg"), "originals"),
|
||||
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.webp"), "thumbnails"),
|
||||
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.jpg"), "thumbnails"),
|
||||
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.webp"), "previews"),
|
||||
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.jpg"), "previews"),
|
||||
]
|
||||
|
||||
|
||||
def encrypt_soft_deleted_media_pack(item_docs, *, actor="system"):
|
||||
"""
|
||||
Archive media files referenced by item docs, encrypt the archive, and delete originals.
|
||||
|
||||
Uses ZIP_STORED (no compression) to keep CPU usage low.
|
||||
"""
|
||||
files_to_archive = []
|
||||
seen_paths = set()
|
||||
|
||||
for item in item_docs:
|
||||
item_id = str(item.get("_id", "unknown"))
|
||||
for image_name in item.get("Images", []) or []:
|
||||
for abs_path, bucket in _candidate_media_paths(str(image_name)):
|
||||
if abs_path in seen_paths:
|
||||
continue
|
||||
if not os.path.isfile(abs_path):
|
||||
continue
|
||||
seen_paths.add(abs_path)
|
||||
files_to_archive.append((item_id, str(image_name), abs_path, bucket))
|
||||
|
||||
if not files_to_archive:
|
||||
return {
|
||||
"archive_created": False,
|
||||
"archived_files": 0,
|
||||
"deleted_files": 0,
|
||||
"archive_path": None,
|
||||
}
|
||||
|
||||
os.makedirs(cfg.DELETED_ARCHIVE_FOLDER, exist_ok=True)
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||
archive_id = f"softdelete-{timestamp}-{uuid.uuid4().hex[:8]}"
|
||||
zip_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip")
|
||||
encrypted_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip.enc")
|
||||
|
||||
manifest = {
|
||||
"archive_id": archive_id,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"actor": actor,
|
||||
"files": [],
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_STORED) as zf:
|
||||
for idx, (item_id, original_name, abs_path, bucket) in enumerate(files_to_archive, start=1):
|
||||
safe_name = os.path.basename(abs_path)
|
||||
arcname = f"{bucket}/{item_id}/{idx:04d}-{safe_name}"
|
||||
zf.write(abs_path, arcname)
|
||||
manifest["files"].append(
|
||||
{
|
||||
"item_id": item_id,
|
||||
"source_name": original_name,
|
||||
"stored_as": arcname,
|
||||
"size_bytes": os.path.getsize(abs_path),
|
||||
}
|
||||
)
|
||||
|
||||
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
|
||||
with open(zip_path, "rb") as source_file:
|
||||
encrypted_payload = _fernet().encrypt(source_file.read())
|
||||
|
||||
with open(encrypted_path, "wb") as encrypted_file:
|
||||
encrypted_file.write(encrypted_payload)
|
||||
|
||||
deleted_files = 0
|
||||
for _, _, abs_path, _ in files_to_archive:
|
||||
try:
|
||||
os.remove(abs_path)
|
||||
deleted_files += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"archive_created": True,
|
||||
"archived_files": len(files_to_archive),
|
||||
"deleted_files": deleted_files,
|
||||
"archive_path": encrypted_path,
|
||||
}
|
||||
+46
-23
@@ -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'])
|
||||
|
||||
@@ -10,4 +10,6 @@ python-dateutil
|
||||
pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
python-barcode
|
||||
openpyxl
|
||||
cryptography
|
||||
+117
-2
@@ -12,6 +12,9 @@ defaults for the web application and helper modules.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import atexit
|
||||
from threading import Lock
|
||||
from pymongo import MongoClient as _PyMongoClient
|
||||
|
||||
# Base directory of this Web package
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -55,6 +58,7 @@ DEFAULTS = {
|
||||
'paths': {
|
||||
'backups': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'backups'),
|
||||
'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'),
|
||||
'deleted_archives': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'deleted_archives'),
|
||||
},
|
||||
'schoolPeriods': {
|
||||
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
|
||||
@@ -98,10 +102,27 @@ def _get(conf, path, default):
|
||||
return default
|
||||
return cur
|
||||
|
||||
|
||||
def _get_bool_env(name, default):
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _get_int_env(name, default):
|
||||
value = os.getenv(name)
|
||||
if value is None or not value.strip():
|
||||
return int(default)
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return int(default)
|
||||
|
||||
# Expose settings
|
||||
APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version'])
|
||||
DEBUG = _get(_conf, ['dbg'], DEFAULTS['debug'])
|
||||
SECRET_KEY = str(_get(_conf, ['key'], DEFAULTS['secret_key']))
|
||||
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
|
||||
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
|
||||
HOST = _get(_conf, ['host'], DEFAULTS['host'])
|
||||
PORT = _get(_conf, ['port'], DEFAULTS['port'])
|
||||
|
||||
@@ -114,6 +135,13 @@ MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
|
||||
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
|
||||
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
|
||||
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
|
||||
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
|
||||
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
|
||||
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
|
||||
MONGODB_CONNECT_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_CONNECT_TIMEOUT_MS', 5000)
|
||||
MONGODB_SERVER_SELECTION_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SERVER_SELECTION_TIMEOUT_MS', 5000)
|
||||
MONGODB_SOCKET_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SOCKET_TIMEOUT_MS', 30000)
|
||||
MONGODB_MAX_CONNECTING = _get_int_env('INVENTAR_MONGODB_MAX_CONNECTING', 2)
|
||||
|
||||
# Scheduler
|
||||
SCHEDULER_INTERVAL_MIN = _get(_conf, ['scheduler', 'interval_minutes'], DEFAULTS['scheduler']['interval_minutes'])
|
||||
@@ -161,10 +189,12 @@ PREVIEW_SIZE = (int(PREVIEW_SIZE_LIST[0]), int(PREVIEW_SIZE_LIST[1])) if isinsta
|
||||
|
||||
BACKUP_FOLDER = _get(_conf, ['paths', 'backups'], DEFAULTS['paths']['backups'])
|
||||
LOGS_FOLDER = _get(_conf, ['paths', 'logs'], DEFAULTS['paths']['logs'])
|
||||
DELETED_ARCHIVE_FOLDER = _get(_conf, ['paths', 'deleted_archives'], DEFAULTS['paths']['deleted_archives'])
|
||||
|
||||
# Optional environment overrides for writable storage mounts.
|
||||
BACKUP_FOLDER = os.getenv('INVENTAR_BACKUP_FOLDER', BACKUP_FOLDER)
|
||||
LOGS_FOLDER = os.getenv('INVENTAR_LOGS_FOLDER', LOGS_FOLDER)
|
||||
DELETED_ARCHIVE_FOLDER = os.getenv('INVENTAR_DELETED_ARCHIVE_FOLDER', DELETED_ARCHIVE_FOLDER)
|
||||
|
||||
# Normalize backup and logs paths to absolute paths (similar to upload folders) to avoid
|
||||
# permission issues caused by relative paths resolving to unintended working dirs.
|
||||
@@ -173,3 +203,88 @@ 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)
|
||||
if not os.path.isabs(DELETED_ARCHIVE_FOLDER):
|
||||
DELETED_ARCHIVE_FOLDER = os.path.join(PROJECT_ROOT, DELETED_ARCHIVE_FOLDER)
|
||||
|
||||
# Optional key for field/file encryption at application level.
|
||||
DATA_ENCRYPTION_KEY = os.getenv('INVENTAR_DATA_ENCRYPTION_KEY', '').strip()
|
||||
|
||||
|
||||
_MONGO_CLIENT_CACHE = {}
|
||||
_MONGO_CLIENT_LOCK = Lock()
|
||||
|
||||
|
||||
class _MongoClientProxy:
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._client, name)
|
||||
|
||||
def __getitem__(self, name):
|
||||
return self._client[name]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def _close_cached_mongo_clients():
|
||||
with _MONGO_CLIENT_LOCK:
|
||||
clients = list(_MONGO_CLIENT_CACHE.values())
|
||||
_MONGO_CLIENT_CACHE.clear()
|
||||
|
||||
for proxy in clients:
|
||||
try:
|
||||
proxy._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(_close_cached_mongo_clients)
|
||||
|
||||
|
||||
def MongoClient(*args, **kwargs):
|
||||
"""Return a process-local MongoDB client configured from this settings module."""
|
||||
explicit_host = 'host' in kwargs
|
||||
explicit_port = 'port' in kwargs
|
||||
host = args[0] if len(args) >= 1 else kwargs.pop('host', MONGODB_HOST)
|
||||
port = args[1] if len(args) >= 2 else kwargs.pop('port', MONGODB_PORT)
|
||||
client_kwargs = {
|
||||
'maxPoolSize': MONGODB_MAX_POOL_SIZE,
|
||||
'minPoolSize': MONGODB_MIN_POOL_SIZE,
|
||||
'maxIdleTimeMS': MONGODB_MAX_IDLE_TIME_MS,
|
||||
'connectTimeoutMS': MONGODB_CONNECT_TIMEOUT_MS,
|
||||
'serverSelectionTimeoutMS': MONGODB_SERVER_SELECTION_TIMEOUT_MS,
|
||||
'socketTimeoutMS': MONGODB_SOCKET_TIMEOUT_MS,
|
||||
'maxConnecting': MONGODB_MAX_CONNECTING,
|
||||
'retryWrites': True,
|
||||
'retryReads': True,
|
||||
}
|
||||
client_kwargs.update(kwargs)
|
||||
|
||||
if len(args) >= 2 and not explicit_host and not explicit_port:
|
||||
mongo_args = args
|
||||
else:
|
||||
mongo_args = (host, port)
|
||||
|
||||
cache_key = (
|
||||
mongo_args,
|
||||
tuple(sorted((key, repr(value)) for key, value in client_kwargs.items())),
|
||||
)
|
||||
|
||||
with _MONGO_CLIENT_LOCK:
|
||||
cached_client = _MONGO_CLIENT_CACHE.get(cache_key)
|
||||
if cached_client is not None:
|
||||
return cached_client
|
||||
|
||||
client = _PyMongoClient(*mongo_args, **client_kwargs)
|
||||
|
||||
cached_client = _MongoClientProxy(client)
|
||||
_MONGO_CLIENT_CACHE[cache_key] = cached_client
|
||||
return cached_client
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Defekte Items verwalten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1250px; margin: 18px auto 32px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; margin-bottom:16px;">
|
||||
<div>
|
||||
<h1 style="margin:0;">Defekte Items</h1>
|
||||
<p style="margin:6px 0 0; color:#64748b;">Eigenes Verwaltungsfenster fuer gemeldete Defekte mit schneller Reparatur-Funktion.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Ausleihen</a>
|
||||
{% if library_module_enabled %}
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Bibliothek</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
|
||||
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
|
||||
</div>
|
||||
|
||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
|
||||
<table id="damage-table" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Ausleihe</th>
|
||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in damaged_items %}
|
||||
<tr class="damage-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.item_type ~ ' ' ~ item.borrow_user ~ ' ' ~ item.latest_damage_description)|lower }}">
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="font-weight:700;">{{ item.name }}</div>
|
||||
<div style="font-size:0.86rem; color:#64748b;">{{ item.code or '-' }} | {{ item.item_type or '-' }}</div>
|
||||
{% if item.author %}<div style="font-size:0.82rem; color:#64748b;">{{ item.author }}</div>{% endif %}
|
||||
{% if item.isbn %}<div style="font-size:0.82rem; color:#64748b;">ISBN: {{ item.isbn }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Defekt</div>
|
||||
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">Meldungen: {{ item.damage_count }}</div>
|
||||
<div style="font-size:0.84rem; color:#475569;">Condition: {{ item.condition or '-' }}</div>
|
||||
<div style="font-size:0.84rem; color:#475569;">Verfuegbar: {{ 'Ja' if item.available else 'Nein' }}</div>
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<div style="font-size:0.9rem; color:#1f2937;">{{ item.latest_damage_description or '-' }}</div>
|
||||
<div style="font-size:0.82rem; color:#64748b; margin-top:4px;">
|
||||
Gemeldet von {{ item.latest_damage_by or '-' }}
|
||||
{% if item.latest_damage_at %} am {{ item.latest_damage_at.strftime('%d.%m.%Y %H:%M') }}{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
{% if item.active_borrow %}
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dbeafe; color:#1d4ed8; font-size:0.75rem; font-weight:700;">Aktiv/Geplant</div>
|
||||
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">User: {{ item.active_borrow.User or item.borrow_user or '-' }}</div>
|
||||
{% if item.active_borrow.End %}
|
||||
<div style="font-size:0.84rem; color:#475569;">Ende: {{ item.active_borrow.End.strftime('%d.%m.%Y %H:%M') }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Keine offene Ausleihe</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
|
||||
<button class="btn btn-success btn-sm" onclick="repairDamage('{{ item.id }}', this)">Als repariert markieren</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not damaged_items %}
|
||||
<div style="padding:24px; color:#64748b; text-align:center;">Keine defekten Items vorhanden.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const searchInput = document.getElementById('damage-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const q = (searchInput.value || '').toLowerCase();
|
||||
document.querySelectorAll('.damage-row').forEach(function(row) {
|
||||
const text = row.getAttribute('data-search') || '';
|
||||
row.style.display = text.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function repairDamage(itemId, button) {
|
||||
if (!itemId) return;
|
||||
if (!confirm('Alle offenen Defektmeldungen fuer dieses Item als repariert markieren?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
fetch('/mark_damage_repaired/' + itemId, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Reparatur fehlgeschlagen');
|
||||
}
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function(err) {
|
||||
button.disabled = false;
|
||||
alert(err.message || 'Fehler beim Markieren als repariert.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
+471
-14
@@ -7,7 +7,7 @@
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-module="{{ CURRENT_MODULE }}">
|
||||
<html lang="de" data-module="{{ CURRENT_MODULE }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
|
||||
@@ -63,6 +63,9 @@
|
||||
.navbar {
|
||||
box-shadow: 0 4px 14px rgba(2, 6, 23, 0.24);
|
||||
background-color: var(--module-primary-color) !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1900;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
@@ -82,6 +85,65 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.nav-active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.function-search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
width: min(420px, 42vw);
|
||||
}
|
||||
|
||||
.function-search-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.function-search-input {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.function-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.function-search-input:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.35);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.function-search-btn {
|
||||
min-height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
padding: 7px 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.function-search-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.quick-link-pill {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover,
|
||||
.navbar-nav .nav-link:focus {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
@@ -255,6 +317,27 @@
|
||||
.nav-item.dropdown .dropdown-toggle {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.function-search-wrap {
|
||||
width: 100%;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item {
|
||||
margin-right: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Better touch targets for mobile */
|
||||
.dropdown-toggle::after {
|
||||
@@ -363,6 +446,7 @@
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% set current_path = request.path %}
|
||||
<!-- Module Selector Bar -->
|
||||
{% if 'username' in session %}
|
||||
<div class="module-selector-bar" id="moduleBar">
|
||||
@@ -396,16 +480,24 @@
|
||||
<div class="collapse navbar-collapse" id="inventoryNavContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link" href="{{ url_for('home') }}">Artikel</a>
|
||||
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
|
||||
</li>
|
||||
{% if 'username' in session %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
|
||||
</li>
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link nav-priority-link" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
||||
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
⋯ Mehr
|
||||
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
||||
Mehr Optionen
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
@@ -418,6 +510,8 @@
|
||||
<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_damaged_items') }}">Defekte Items</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>
|
||||
@@ -433,12 +527,28 @@
|
||||
</ul>
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<div class="function-search-wrap">
|
||||
<form class="function-search-form" data-function-search="true">
|
||||
<input
|
||||
class="function-search-input"
|
||||
type="search"
|
||||
name="function_search"
|
||||
placeholder="Funktion suchen..."
|
||||
list="function-search-options"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button class="function-search-btn" type="submit">Los</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
@@ -461,16 +571,24 @@
|
||||
<div class="collapse navbar-collapse" id="libraryNavContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link" href="{{ url_for('library_view') }}">Medien</a>
|
||||
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
|
||||
</li>
|
||||
{% if 'username' in session %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
|
||||
</li>
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link nav-priority-link" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
||||
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
⋯ Mehr
|
||||
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
||||
Mehr Optionen
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
||||
{% if 'username' in session %}
|
||||
@@ -481,8 +599,9 @@
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
|
||||
{% if student_cards_module_enabled %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Schülerausweise</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">System</h6></li>
|
||||
@@ -498,12 +617,28 @@
|
||||
</ul>
|
||||
<div class="d-flex">
|
||||
{% if 'username' in session %}
|
||||
<div class="function-search-wrap">
|
||||
<form class="function-search-form" data-function-search="true">
|
||||
<input
|
||||
class="function-search-input"
|
||||
type="search"
|
||||
name="function_search"
|
||||
placeholder="Funktion suchen..."
|
||||
list="function-search-options"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button class="function-search-btn" type="submit">Los</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||
👤
|
||||
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
|
||||
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
@@ -596,6 +731,73 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.notif-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
padding: 0 6px;
|
||||
margin-left: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-notification-dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.35);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-notification-dot.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-toast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 18px;
|
||||
z-index: 2300;
|
||||
max-width: 360px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 18px 35px rgba(2, 6, 23, 0.4);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-toast strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notification-toast.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
|
||||
<div class="cb-inner">
|
||||
@@ -623,6 +825,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
|
||||
<strong id="notification-toast-title">Neue Benachrichtigung</strong>
|
||||
<span id="notification-toast-message"></span>
|
||||
</div>
|
||||
|
||||
<datalist id="function-search-options">
|
||||
<option value="Artikel"></option>
|
||||
<option value="Meine Ausleihen"></option>
|
||||
<option value="Benachrichtigungen"></option>
|
||||
<option value="Tutorial"></option>
|
||||
<option value="Impressum"></option>
|
||||
<option value="Lizenz"></option>
|
||||
{% if library_module_enabled %}
|
||||
<option value="Bibliothek"></option>
|
||||
<option value="Meine Medien"></option>
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
<option value="Hochladen"></option>
|
||||
<option value="Ausleihen Verwaltung"></option>
|
||||
<option value="Defekte Items"></option>
|
||||
<option value="Filter verwalten"></option>
|
||||
<option value="Orte verwalten"></option>
|
||||
<option value="Audit Dashboard"></option>
|
||||
<option value="Logs"></option>
|
||||
<option value="Benutzer verwalten"></option>
|
||||
<option value="Neuer Benutzer"></option>
|
||||
{% if student_cards_module_enabled %}
|
||||
<option value="Bibliotheksausweis"></option>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</datalist>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function getCookie(name){
|
||||
@@ -656,8 +890,231 @@
|
||||
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
|
||||
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
|
||||
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
|
||||
const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
|
||||
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
|
||||
const onboardingOverlay = document.getElementById('onboarding-overlay');
|
||||
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
|
||||
const notificationToast = document.getElementById('notification-toast');
|
||||
const notificationToastTitle = document.getElementById('notification-toast-title');
|
||||
const notificationToastMessage = document.getElementById('notification-toast-message');
|
||||
let lastUnreadCount = Number({{ unread_notification_count|default(0)|int }});
|
||||
const loginHintKey = username ? ('inventarsystem_notification_login_hint_v1_' + username) : null;
|
||||
|
||||
const functionSearchEntries = [
|
||||
{ label: 'Artikel', keywords: ['artikel', 'inventar', 'home'], url: {{ url_for('home')|tojson }} },
|
||||
{ label: 'Meine Ausleihen', keywords: ['meine ausleihen', 'ausleihen', 'borrowed'], url: {{ url_for('my_borrowed_items')|tojson }} },
|
||||
{ label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} },
|
||||
{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
|
||||
{ label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} },
|
||||
{ label: 'Lizenz', keywords: ['lizenz', 'license'], url: {{ url_for('license')|tojson }} },
|
||||
{% if library_module_enabled %}
|
||||
{ label: 'Bibliothek', keywords: ['bibliothek', 'medien'], url: {{ url_for('library_view')|tojson }} },
|
||||
{% endif %}
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
|
||||
{ label: 'Hochladen', keywords: ['hochladen', 'upload'], url: {{ url_for('upload_admin')|tojson }} },
|
||||
{ label: 'Ausleihen Verwaltung', keywords: ['ausleihen verwaltung', 'admin borrowings'], url: {{ url_for('admin_borrowings')|tojson }} },
|
||||
{ label: 'Defekte Items', keywords: ['defekte items', 'defekt', 'schaden'], url: {{ url_for('admin_damaged_items')|tojson }} },
|
||||
{ label: 'Filter verwalten', keywords: ['filter verwalten', 'filter'], url: {{ url_for('manage_filters')|tojson }} },
|
||||
{ label: 'Orte verwalten', keywords: ['orte verwalten', 'orte', 'location'], url: {{ url_for('manage_locations')|tojson }} },
|
||||
{ label: 'Audit Dashboard', keywords: ['audit', 'audit dashboard'], url: {{ url_for('admin_audit_dashboard')|tojson }} },
|
||||
{ label: 'Logs', keywords: ['logs', 'protokoll'], url: {{ url_for('logs')|tojson }} },
|
||||
{ label: 'Benutzer verwalten', keywords: ['benutzer verwalten', 'user'], url: {{ url_for('user_del')|tojson }} },
|
||||
{ label: 'Neuer Benutzer', keywords: ['neuer benutzer', 'register'], url: {{ url_for('register')|tojson }} },
|
||||
{% if library_module_enabled %}
|
||||
{ label: 'Bibliotheks-Ausleihen', keywords: ['bibliotheks ausleihen', 'library loans'], url: {{ url_for('library_loans_admin')|tojson }} },
|
||||
{% endif %}
|
||||
{% if student_cards_module_enabled %}
|
||||
{ label: 'Bibliotheksausweis', keywords: ['bibliotheksausweis', 'student card'], url: {{ url_for('student_cards_admin')|tojson }} },
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
];
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[ä]/g, 'ae')
|
||||
.replace(/[ö]/g, 'oe')
|
||||
.replace(/[ü]/g, 'ue')
|
||||
.replace(/[ß]/g, 'ss')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findFunctionRoute(rawValue) {
|
||||
const input = normalizeSearchText(rawValue);
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let exact = null;
|
||||
for (const entry of functionSearchEntries) {
|
||||
const candidates = [entry.label].concat(entry.keywords || []);
|
||||
for (const candidate of candidates) {
|
||||
if (normalizeSearchText(candidate) === input) {
|
||||
exact = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exact) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exact) {
|
||||
return exact.url;
|
||||
}
|
||||
|
||||
for (const entry of functionSearchEntries) {
|
||||
const candidates = [entry.label].concat(entry.keywords || []);
|
||||
for (const candidate of candidates) {
|
||||
if (normalizeSearchText(candidate).includes(input) || input.includes(normalizeSearchText(candidate))) {
|
||||
return entry.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function bindFunctionSearchForms() {
|
||||
const forms = document.querySelectorAll('form[data-function-search="true"]');
|
||||
forms.forEach(function(form) {
|
||||
form.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
const input = form.querySelector('input[name="function_search"]');
|
||||
const targetUrl = findFunctionRoute(input ? input.value : '');
|
||||
if (targetUrl) {
|
||||
window.location.href = targetUrl;
|
||||
return;
|
||||
}
|
||||
showInAppNotification('Keine Funktion gefunden', 'Bitte Suche verfeinern, z.B. "Defekte Items" oder "Benachrichtigungen".');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bindFunctionSearchForms();
|
||||
|
||||
function maybeShowLoginNotificationHint() {
|
||||
if (!username || !loginHintKey) {
|
||||
return;
|
||||
}
|
||||
if (window.location.pathname === notificationsPagePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyShown = sessionStorage.getItem(loginHintKey);
|
||||
if (alreadyShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastUnreadCount > 0) {
|
||||
const hintTitle = 'Neue Benachrichtigungen';
|
||||
const hintMessage =
|
||||
lastUnreadCount === 1
|
||||
? 'Sie haben 1 neue Benachrichtigung. Im Nutzer-Menue finden Sie den Eintrag Benachrichtigungen.'
|
||||
: 'Sie haben ' + lastUnreadCount + ' neue Benachrichtigungen. Im Nutzer-Menue finden Sie den Eintrag Benachrichtigungen.';
|
||||
showInAppNotification(hintTitle, hintMessage);
|
||||
}
|
||||
|
||||
sessionStorage.setItem(loginHintKey, 'shown');
|
||||
}
|
||||
|
||||
maybeShowLoginNotificationHint();
|
||||
|
||||
function updateNotificationDots(unreadCount) {
|
||||
const hasUnread = Number(unreadCount) > 0;
|
||||
notificationButtons.forEach(function(btn) {
|
||||
const dot = btn.querySelector('.user-notification-dot');
|
||||
if (!dot) {
|
||||
return;
|
||||
}
|
||||
if (hasUnread) {
|
||||
dot.classList.add('visible');
|
||||
} else {
|
||||
dot.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showInAppNotification(title, message) {
|
||||
if (!notificationToast || !notificationToastTitle || !notificationToastMessage) {
|
||||
return;
|
||||
}
|
||||
notificationToastTitle.textContent = title || 'Neue Benachrichtigung';
|
||||
notificationToastMessage.textContent = message || '';
|
||||
notificationToast.classList.add('show');
|
||||
window.setTimeout(function() {
|
||||
notificationToast.classList.remove('show');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showBrowserNotification(title, message) {
|
||||
if (!('Notification' in window)) {
|
||||
showInAppNotification(title, message);
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title || 'Neue Benachrichtigung', {
|
||||
body: message || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(function(permission) {
|
||||
if (permission === 'granted') {
|
||||
new Notification(title || 'Neue Benachrichtigung', {
|
||||
body: message || '',
|
||||
});
|
||||
} else {
|
||||
showInAppNotification(title, message);
|
||||
}
|
||||
}).catch(function() {
|
||||
showInAppNotification(title, message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
showInAppNotification(title, message);
|
||||
}
|
||||
|
||||
function pollNotificationStatus() {
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/notifications/unread_status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('status request failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data || !data.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unreadCount = Number(data.unread_count || 0);
|
||||
updateNotificationDots(unreadCount);
|
||||
|
||||
if (unreadCount > lastUnreadCount && window.location.pathname !== notificationsPagePath) {
|
||||
const latest = data.latest_unread || {};
|
||||
showBrowserNotification(latest.title || 'Neue Nachricht', latest.message || 'Es gibt neue Benachrichtigungen.');
|
||||
}
|
||||
|
||||
lastUnreadCount = unreadCount;
|
||||
})
|
||||
.catch(function() {
|
||||
// Silent fail; polling retries automatically.
|
||||
});
|
||||
}
|
||||
|
||||
updateNotificationDots(lastUnreadCount);
|
||||
if (username) {
|
||||
window.setInterval(pollNotificationStatus, 30000);
|
||||
}
|
||||
|
||||
function showOnboarding(){
|
||||
if (onboardingOverlay) {
|
||||
|
||||
@@ -14,20 +14,21 @@
|
||||
<div class="container my-4">
|
||||
<div class="impressum-content bg-white p-4 shadow rounded">
|
||||
<h1 class="mb-4 text-center">Impressum</h1>
|
||||
<p class="text-center mb-5">(Angaben gemäß § 5 TMG)</p>
|
||||
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
|
||||
|
||||
<div class="impressum-section mb-4">
|
||||
<h3 class="mb-3">Kontaktinformationen</h3>
|
||||
<p><strong>Name:</strong> . ..</p>
|
||||
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
|
||||
<p><strong>E-Mail:</strong> <a href="mailto:kontakt@example.com">kontakt@example.com</a></p>
|
||||
<p><strong>Telefon:</strong> +49 123 456789</p>
|
||||
<address class="mb-0">
|
||||
<p><strong>Name:</strong> Invario UG</p>
|
||||
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
|
||||
</address>
|
||||
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
|
||||
</div>
|
||||
|
||||
<div class="impressum-section mb-4">
|
||||
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
|
||||
<p>(nach § 55 Abs. 2 RStV)</p>
|
||||
<p>...<br>
|
||||
<p>(nach § 18 Abs. 2 MStV)</p>
|
||||
<p>Invario UG<br>
|
||||
Musterstraße 123<br>
|
||||
12345 Musterstadt<br>
|
||||
Deutschland</p>
|
||||
@@ -37,7 +38,7 @@
|
||||
<h3 class="mb-3">Haftungsausschluss</h3>
|
||||
|
||||
<h4 class="mb-2">Haftung für Inhalte</h4>
|
||||
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
|
||||
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
|
||||
|
||||
<h4 class="mb-2">Haftung für Links</h4>
|
||||
<p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.</p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -321,11 +321,6 @@
|
||||
<li><strong>Einwilligung:</strong> Falls private Daten der Nutzer erfasst werden, muss eine explizite
|
||||
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
|
||||
</ol>
|
||||
<div class="license-exception-notice" style="background-color:#f8d7da; border-color:#f5c2c7; border-left-color:#dc3545;">
|
||||
<h3 style="color:#842029;">⚠️ Hinweis</h3>
|
||||
<p>Dieses Dokument stellt <strong>keine Rechtsberatung</strong> dar. Bitte konsultieren Sie im Zweifelsfall
|
||||
einen Fachanwalt für Datenschutzrecht.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /#pane-legal -->
|
||||
|
||||
|
||||
+219
-9
@@ -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;
|
||||
@@ -2737,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;
|
||||
|
||||
+529
-13
@@ -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,49 @@
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.bulk-delete-row {
|
||||
display: none;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulk-delete-mode-active .bulk-delete-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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 +2309,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 +2709,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 +3021,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 +3041,139 @@ 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');
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
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';
|
||||
}
|
||||
// Toggle checkbox visibility: show checkboxes only when bulk delete mode is active
|
||||
if (itemsContainer) {
|
||||
itemsContainer.classList.toggle('bulk-delete-mode-active', bulkDeleteDrawerOpen);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -2963,8 +3315,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');
|
||||
@@ -2974,9 +3394,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>
|
||||
@@ -2989,15 +3413,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');
|
||||
@@ -3148,6 +3582,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 ? `
|
||||
@@ -3223,8 +3663,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();
|
||||
@@ -3237,9 +3680,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 =
|
||||
@@ -3250,6 +3705,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;
|
||||
@@ -3344,7 +3860,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)) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Benachrichtigungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1080px; margin: 18px auto 32px;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:16px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<h1 style="margin:0;">Benachrichtigungen</h1>
|
||||
<p style="margin:6px 0 0; color:#64748b;">Rueckgabe-Erinnerungen und wichtige Hinweise fuer Benutzer und Verwaltung.</p>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('mark_all_notifications_read') }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Alle als gelesen markieren</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
|
||||
{% if user_notifications %}
|
||||
<div style="display:grid; gap:10px;">
|
||||
{% for n in user_notifications %}
|
||||
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
|
||||
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
|
||||
{% if n.type == 'damage_reported' %}
|
||||
<div style="margin-top:8px;">
|
||||
<a class="btn btn-sm btn-outline-danger" href="{{ url_for('admin_damaged_items') }}">Zu Defekte-Items Verwaltung</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
|
||||
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not n.is_read %}
|
||||
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#64748b; margin:0;">Keine Benachrichtigungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if is_admin_user %}
|
||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
|
||||
{% if admin_notifications %}
|
||||
<div style="display:grid; gap:10px;">
|
||||
{% for n in admin_notifications %}
|
||||
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
|
||||
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
|
||||
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
|
||||
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not n.is_read %}
|
||||
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#64748b; margin:0;">Keine Admin-Benachrichtigungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -124,12 +124,12 @@
|
||||
<div class="container">
|
||||
<div class="icon">📇</div>
|
||||
<h1>Schülerausweis-Download</h1>
|
||||
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p>
|
||||
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>📌 Was wird heruntergeladen?</strong>
|
||||
<p>Eine druckfertige PDF mit:</p>
|
||||
<p>✓ Alle Schülerausweise</p>
|
||||
<p>✓ Alle Bibliotheksausweise</p>
|
||||
<p>✓ Scanbare CODE128 Barcodes</p>
|
||||
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
|
||||
<p>✓ Professionelle Qualität</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Schülerausweise - Inventarsystem{% endblock %}
|
||||
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
@@ -229,7 +229,7 @@
|
||||
<div class="container">
|
||||
<div class="student-card-header">
|
||||
<div>
|
||||
<h1>📚 Schülerausweise (Bibliotek)</h1>
|
||||
<h1>📚 Bibliotheksausweise (Bibliotek)</h1>
|
||||
</div>
|
||||
<div class="export-buttons">
|
||||
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
|
||||
@@ -342,7 +342,7 @@
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Keine Schülerausweise registriert.</p>
|
||||
<p>Keine Bibliotheksausweise registriert.</p>
|
||||
<p>Fügen Sie ein neues Ausweis oben hinzu.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
+6
-1
@@ -30,6 +30,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: inventarsystem-app
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
@@ -39,17 +41,19 @@ services:
|
||||
INVENTAR_MONGODB_DB: Inventarsystem
|
||||
INVENTAR_BACKUP_FOLDER: /data/backups
|
||||
INVENTAR_LOGS_FOLDER: /data/logs
|
||||
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./Web:/app/Web
|
||||
- ./Web:/app/Web:ro
|
||||
- app_uploads:/app/Web/uploads
|
||||
- app_thumbnails:/app/Web/thumbnails
|
||||
- app_previews:/app/Web/previews
|
||||
- app_qrcodes:/app/Web/QRCodes
|
||||
- app_backups:/data/backups
|
||||
- app_logs:/data/logs
|
||||
- app_deleted_archives:/data/deleted-archives
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
@@ -59,3 +63,4 @@ volumes:
|
||||
app_qrcodes:
|
||||
app_backups:
|
||||
app_logs:
|
||||
app_deleted_archives:
|
||||
|
||||
+3
-1
@@ -9,4 +9,6 @@ python-dateutil
|
||||
pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
python-barcode
|
||||
openpyxl
|
||||
cryptography
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user