Compare commits

..

47 Commits

Author SHA1 Message Date
Aiirondev_dev 5d8213b8c2 Implement encryption for student card data and add support for archiving deleted media files 2026-04-17 17:14:08 +02:00
Aiirondev_dev b94fb1cd97 Enhance security and logging in application settings and templates; update impressum legal references 2026-04-17 16:54:53 +02:00
Aiirondev_dev ce20f569a3 Fix email link in impressum section 2026-04-13 21:58:14 +02:00
Aiirondev_dev ec7f32994c Remove notifications link and update impressum contact information 2026-04-13 21:44:06 +02:00
Aiirondev_dev 8358bf257f Add login notification hint for unread messages in user menu 2026-04-13 21:30:24 +02:00
Aiirondev_dev 193495d6a2 Enhance navbar with sticky positioning and search functionality, including active link highlighting and improved mobile responsiveness 2026-04-13 21:27:12 +02:00
Aiirondev_dev 5afcaa5e19 Add damage report link to notification details 2026-04-13 21:19:32 +02:00
Aiirondev_dev 3aa44b64b8 Add notification system with unread count and in-app alerts 2026-04-13 21:05:34 +02:00
Aiirondev_dev 86d8f19313 Implement notification system with user and admin views, including unread count and reminder functionality for damaged items 2026-04-13 20:29:50 +02:00
Maximilian G. e74798f252 Update support status for version 3.0.x 2026-04-13 12:10:03 +02:00
Aiirondev_dev 31c3d700a8 Update terminology from "Schülerausweise" to "Bibliotheksausweis" across admin and print templates for consistency 2026-04-12 21:31:11 +02:00
Aiirondev_dev a3c114e4b5 Refactor bulk delete functionality to toggle visibility of checkboxes in admin view 2026-04-12 21:21:50 +02:00
Aiirondev_dev 170a2a53ab Reduce Gunicorn worker count and adjust timeout settings for optimized performance 2026-04-12 20:57:41 +02:00
Aiirondev_dev a8ac08d5b0 Implement light mode for item loading to optimize initial data fetch 2026-04-12 20:36:13 +02:00
Aiirondev_dev 05c4c73635 Add loading indicator for main items in admin view to enhance user experience 2026-04-12 20:21:47 +02:00
Aiirondev_dev 76dd00831e Enhance item count display logic in admin view for improved accuracy 2026-04-12 20:02:19 +02:00
Aiirondev_dev 205a56ff24 Update filter display function calls for improved clarity in UI updates 2026-04-12 19:47:56 +02:00
Aiirondev_dev 60ec21f34e Implement lazy loading and prefetching for main admin items to enhance performance 2026-04-12 19:30:20 +02:00
Aiirondev_dev 57f6d2f8e7 Refactor MongoClient function to enhance configuration handling and preserve caller arguments 2026-04-12 19:18:30 +02:00
Aiirondev_dev 53575c1876 Refactor MongoDB client initialization for improved performance and simplicity 2026-04-12 17:50:24 +02:00
Aiirondev_dev 0f372f5056 Refactor MongoDB client imports and implement lazy loading for main items in UI 2026-04-12 17:36:49 +02:00
Aiirondev_dev c078de0076 Enhance library items API with pagination support and improve loading mechanism in UI 2026-04-12 17:22:49 +02:00
Aiirondev_dev daccf3334b Refactor bulk delete drawer for improved layout and accessibility 2026-04-10 23:20:20 +02:00
Aiirondev_dev ef605e080b Refactor bulk delete drawer for improved UI and functionality 2026-04-10 23:14:27 +02:00
Aiirondev_dev 1755ec222d Enhance bulk delete drawer UI with improved styles and button text 2026-04-10 23:08:02 +02:00
Aiirondev_dev 06ab3e1f4d Implement bulk delete drawer functionality with UI enhancements 2026-04-10 22:38:24 +02:00
Aiirondev_dev a2c79c80bc Implement bulk delete functionality for item groups with UI enhancements 2026-04-10 22:32:07 +02:00
Aiirondev_dev 7ac66ae3ba Refactor code structure for improved readability and maintainability 2026-04-10 22:08:25 +02:00
Aiirondev_dev 444a1df1aa Add audit review export functionality in markdown and JSON formats 2026-04-10 21:22:32 +02:00
Aiirondev_dev 0581b5c5e9 Add standalone audit event logging for borrowing and returning actions 2026-04-10 20:51:01 +02:00
Aiirondev_dev 2faf284a0e Implement tamper-evident audit logging and invoice correction features
- Introduced a new audit logging system that creates a tamper-evident chain of events in MongoDB.
- Added functions for appending audit events, ensuring index integrity, and verifying the audit chain.
- Implemented soft-delete functionality for inventory and borrowing records to comply with GoBD regulations.
- Added UI components for displaying invoice corrections and audit dashboard, including mismatch reporting.
- Created a CLI utility for verifying the integrity of the audit chain.
- Enhanced invoice management by preventing overwrites and allowing correction entries with reasons and optional deltas.
2026-04-10 20:32:56 +02:00
Aiirondev_dev 025e03f9ce Enhance image handling in admin template: add dynamic input management and improve UI for existing images 2026-04-10 17:23:56 +02:00
Aiirondev_dev a96e103733 Add bulk selection functionality for filter options in admin templates 2026-04-10 17:13:09 +02:00
Aiirondev_dev b636d06908 Add function to ensure runtime config JSON is created and backup unexpected directories 2026-04-10 16:42:36 +02:00
Aiirondev_dev a85461711c Add config.json handling for Docker setup and update script 2026-04-10 16:35:49 +02:00
Aiirondev_dev bf0a50ad57 Add module detection for dynamic navbar rendering 2026-04-10 16:22:03 +02:00
Aiirondev_dev 9212ad04f5 Add asset versioning for cache-busting in templates and configuration 2026-04-10 16:10:30 +02:00
Aiirondev_dev 1931e07013 Add function to refresh runtime scripts from main repository and update image tags 2026-04-10 16:02:18 +02:00
Maximilian G. c81aa882e9 Add badges for CI and coding activity
Added badges for GitHub Actions and Wakatime to README.
2026-04-10 15:57:44 +02:00
Aiirondev_dev 142e6e990e Update repository slug and image repository in update script 2026-04-10 15:56:42 +02:00
Aiirondev_dev 9333b5cdd0 Add versioning to CSS file links in base template 2026-04-10 15:51:35 +02:00
Aiirondev_dev b11739de3b Add compatibility alias for legacy compose bundles in installer script 2026-04-10 15:37:01 +02:00
Aiirondev_dev 269f084227 Add function to pin application image in docker-compose file 2026-04-10 15:33:15 +02:00
Aiirondev_dev 46b9e0b3b2 Add function to refresh start script from main repository 2026-04-10 15:29:56 +02:00
Aiirondev_dev 4554e5ee02 Refactor installation scripts to use consistent repository slug and improve temporary directory cleanup 2026-04-10 15:21:24 +02:00
Aiirondev_dev ecea6740f5 Update installation script URLs to reflect correct GitHub user slug 2026-04-10 15:16:59 +02:00
Aiirondev_dev a8122f9afd Refactor installer script to update repository slug and correct application image path 2026-04-10 15:14:00 +02:00
42 changed files with 4736 additions and 366 deletions
+2
View File
@@ -203,6 +203,7 @@ jobs:
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
@@ -225,6 +226,7 @@ jobs:
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
cp backup.sh release-bundle/backup.sh
cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh
cp init-admin.sh release-bundle/init-admin.sh
+2 -1
View File
@@ -1,4 +1,5 @@
dist
logs
certs
build
build
.venv
+1 -1
View File
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
WORKDIR /app/Web
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
+180
View File
@@ -0,0 +1,180 @@
# GoBD Hardening Change Report (2026-04-10)
## Scope
This change set implements core hardening measures for GoBD-oriented operation:
1. Soft-delete instead of hard-delete for inventory and borrowing records.
2. Tamper-evident audit log chain for critical accounting/inventory events.
3. Invoice immutability model with correction entries instead of overwrite.
## Implemented Changes
### 1) Tamper-evident audit chain
- New module: `Web/audit_log.py`
- Audit entries are chained by hash (`prev_hash` -> `entry_hash`) with monotonic `chain_index`.
- Canonical JSON serialization is used to make hashing deterministic.
Current fields per audit event:
- `event_type`
- `actor`
- `source`
- `ip`
- `payload`
- `timestamp`
- `created_at`
- `prev_hash`
- `entry_hash`
- `chain_index`
Integrated event writes in:
- Item soft-delete flow
- Invoice creation
- Invoice paid marking
- Invoice finalize+repair
- Invoice correction entry creation
### 1b) Audit operational controls (phase 2)
- Added index management in `Web/audit_log.py`:
- Unique index on `chain_index`
- Additional indexes on `created_at` and `event_type`
- Added chain verification function in `Web/audit_log.py`.
- Added CLI verification tool: `Web/verify_audit_chain.py`.
- Added admin verification endpoint:
- `GET /admin/audit/verify`
- Returns chain integrity result (`200` if valid, `409` on mismatch).
- Added lazy index provisioning helper invoked in admin borrowing views.
### 2) Soft-delete conversion
#### Inventory items
- Deletion endpoint now marks records logically deleted:
- `Deleted: true`
- `DeletedAt`
- `DeletedBy`
- `LastUpdated`
- `Verfuegbar: false`
- Item-linked borrow records are also logically deleted (`Status: deleted`) instead of physically removed.
- Image files are no longer physically deleted in this flow.
#### Borrow records
- `remove_ausleihung` changed to set `Status: deleted` + timestamps.
- Retrieval helpers now exclude deleted records by default.
#### Item reads
- Item helper queries now exclude `Deleted: true` records.
- Grouped item lookups and appointment queries also exclude deleted records.
- Code uniqueness checks ignore deleted records, allowing controlled code reuse.
### 3) Invoice immutability and correction flow
- Invoice creation now blocks overwrite if an invoice already exists for the borrow record.
- New lock marker on invoice creation/update path: `InvoiceLocked: true`.
- New correction endpoint:
- `POST /admin/borrowings/<borrow_id>/invoice/correction`
- Appends entries to `InvoiceCorrections`
- Does not mutate existing `InvoiceData` body
- Requires correction reason
- Supports optional amount delta
### 3b) UI integration for correction flow (phase 2)
- Added correction action forms in:
- `Web/templates/admin_borrowings.html`
- `Web/templates/library_borrowings_admin.html`
- Added correction count display (`invoice_corrections_count`) in both admin tables.
## Detailed File-Level Review
### `Web/audit_log.py`
- Introduces chain-based audit persistence.
- Uses last chain entry to calculate next `chain_index` and hash.
- Adds explicit index provisioning and full chain verification routine.
- Tradeoff: application-level sequencing is improved by unique index, but concurrent peak writes may still require retry logic around duplicate key conflicts.
### `Web/verify_audit_chain.py`
- New CLI operational tool for manual/cron verification.
- Returns non-zero exit code on chain mismatch.
### `Web/app.py`
- Added `_append_audit_event(...)` helper and integrated it at critical event boundaries.
- Inventory API routes now hide soft-deleted items.
- `delete_item` changed from destructive deletion to soft-delete semantics.
- Invoice route now rejects overwrite and requires correction route for changes.
- Added correction route with immutable invoice core.
- Added admin audit verification route and lazy audit index initialization helper.
Behavioral impact:
- Deleted items no longer disappear from DB; they are hidden from normal views.
- Existing UI actions for delete continue to work, but now preserve evidence.
- Invoice re-creation attempts now return warning and redirect.
### `Web/items.py`
- Introduced `_active_record_query(...)` and applied it across item retrieval APIs.
- Converted `remove_item` to soft-delete update.
- Updated maintenance reset (`unstuck_item`) to status updates instead of `delete_many`.
Behavioral impact:
- Item-level DB history is preserved.
- Legacy scripts relying on hard delete semantics may need adaptation.
### `Web/ausleihung.py`
- Converted `remove_ausleihung` to soft-delete by status.
- Default retrieval paths now exclude deleted records.
Behavioral impact:
- Borrowing history remains in DB for traceability.
## Validation Performed
- Static diagnostics reported no errors in modified files:
- `Web/app.py`
- `Web/items.py`
- `Web/ausleihung.py`
- `Web/audit_log.py`
- Additional phase-2 checks:
- `python3 -m py_compile Web/app.py Web/audit_log.py Web/verify_audit_chain.py`
- No syntax errors
- Template diagnostics:
- `Web/templates/admin_borrowings.html` no errors
- `Web/templates/library_borrowings_admin.html` no errors
## Residual Risks / Open Points
1. Concurrency hardening for audit chain:
- Current chain append may produce collisions under parallel writes.
- Recommendation: transaction or optimistic retry with unique index on `chain_index`.
2. Broader query coverage:
- Some direct Mongo queries outside helper paths may still need explicit `Deleted != true` filters.
3. Formal GoBD process requirements beyond code:
- Verfahrensdokumentation must be updated.
- WORM/immutable storage for exported archives should be enforced externally.
- Operational controls (RBAC review, periodic reconciliation, restore drills) should be documented.
## Recommended Next Steps
1. Add retry strategy for audit write conflicts:
- Retry `append_audit_event` on duplicate `chain_index` key errors.
2. Add admin UI page for chain status and recent audit events:
- Human-readable inspection view on top of `/admin/audit/verify`.
3. Add policy enforcement tests:
- Ensure invoice overwrite is blocked.
- Ensure soft-deleted records are hidden in APIs.
- Ensure deletion endpoints never call physical delete operators.
4. Infrastructure-level immutability:
- Push periodic audit snapshots and invoice archives to immutable/WORM storage.
+5 -3
View File
@@ -1,5 +1,7 @@
# Inventarsystem
[![https://github.com/AIIrondev/legendary-octo-garbanzo](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml/badge.svg)](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml)
[![wakatime](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243.svg)](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243)
**Aktuelle Version: 3.2.1**
@@ -120,19 +122,19 @@ Das Inventarsystem stellt folgende Wartungsskripte bereit:
**Option 1**
```bash
wget -O - https://raw.githubusercontent.com/aiirondev/legendary-octo-garbanzo/main/install.sh | sudo bash
wget -O - https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | sudo bash
```
**Option 2**
```bash
curl -s https://raw.githubusercontent.com/aiirondev/legendary-octo-garbanzo/main/install.sh | sudo bash
curl -s https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | sudo bash
```
Legacy-MongoDB uebernehmen und altes Host-System aufraeumen:
```bash
curl -s https://raw.githubusercontent.com/aiirondev/legendary-octo-garbanzo/main/install.sh | \
curl -s https://raw.githubusercontent.com/AIIrondev/legendary-octo-garbanzo/main/install.sh | \
sudo bash -s -- --migrate-legacy-db --remove-legacy-system
```
+2 -1
View File
@@ -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.
+1891 -129
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
"""
Tamper-evident audit logging helpers.
The audit chain stores each entry with a hash of the previous entry.
Any mutation in history breaks the chain verification.
"""
import datetime
import hashlib
import json
import random
import time
from pymongo.errors import DuplicateKeyError
def _stable_json(value):
"""Serialize dictionaries in a stable way for deterministic hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
def _entry_hash(prev_hash, payload):
"""Build the chained entry hash from previous hash + canonical payload."""
base = f"{prev_hash}|{_stable_json(payload)}"
return hashlib.sha256(base.encode("utf-8")).hexdigest()
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
"""
Append an audit event to a tamper-evident chain.
Args:
db: MongoDB database handle.
event_type (str): Event category.
actor (str): User/system who performed the action.
payload (dict): Event details.
request_ip (str, optional): Request origin.
source (str): Source subsystem.
Returns:
dict: Inserted audit entry.
"""
logs = db["audit_log"]
attempts = 0
while attempts <= max_retries:
previous = logs.find_one(sort=[("chain_index", -1)])
prev_hash = previous.get("entry_hash", "") if previous else ""
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
timestamp = datetime.datetime.utcnow()
entry_payload = {
"event_type": event_type,
"actor": actor or "system",
"source": source,
"ip": request_ip or "",
"payload": payload or {},
"timestamp": timestamp.isoformat() + "Z",
}
entry_hash = _entry_hash(prev_hash, entry_payload)
entry = {
**entry_payload,
"created_at": timestamp,
"prev_hash": prev_hash,
"entry_hash": entry_hash,
"chain_index": chain_index,
}
try:
logs.insert_one(entry)
return entry
except DuplicateKeyError:
attempts += 1
if attempts > max_retries:
raise
# Exponential backoff with jitter to avoid retry storms.
delay = min(0.25, (0.005 * (2 ** attempts)) + random.random() * 0.01)
time.sleep(delay)
def ensure_audit_indexes(db):
"""Create indexes required for fast and safe audit operations."""
logs = db["audit_log"]
logs.create_index("chain_index", unique=True, name="audit_chain_index_unique")
logs.create_index("created_at", name="audit_created_at_idx")
logs.create_index("event_type", name="audit_event_type_idx")
def verify_audit_chain(db):
"""Verify hash chain integrity across all stored audit entries."""
logs = db["audit_log"]
entries = list(logs.find({}, {"_id": 1, "event_type": 1, "actor": 1, "source": 1, "ip": 1, "payload": 1, "timestamp": 1, "prev_hash": 1, "entry_hash": 1, "chain_index": 1}).sort("chain_index", 1))
previous_hash = ""
previous_index = 0
mismatches = []
for entry in entries:
chain_index = int(entry.get("chain_index", 0))
prev_hash = entry.get("prev_hash", "")
entry_hash = entry.get("entry_hash", "")
payload = {
"event_type": entry.get("event_type", ""),
"actor": entry.get("actor", ""),
"source": entry.get("source", ""),
"ip": entry.get("ip", ""),
"payload": entry.get("payload", {}),
"timestamp": entry.get("timestamp", ""),
}
expected_hash = _entry_hash(previous_hash, payload)
if chain_index != previous_index + 1:
mismatches.append({
"chain_index": chain_index,
"error": "chain_index_gap",
"expected": previous_index + 1,
"found": chain_index,
})
if prev_hash != previous_hash:
mismatches.append({
"chain_index": chain_index,
"error": "prev_hash_mismatch",
"expected": previous_hash,
"found": prev_hash,
})
if entry_hash != expected_hash:
mismatches.append({
"chain_index": chain_index,
"error": "entry_hash_mismatch",
"expected": expected_hash,
"found": entry_hash,
})
previous_hash = entry_hash
previous_index = chain_index
return {
"ok": len(mismatches) == 0,
"count": len(entries),
"last_chain_index": previous_index,
"last_hash": previous_hash,
"mismatches": mismatches,
}
+20 -12
View File
@@ -25,7 +25,6 @@ Sammlungsstruktur:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import pytz
@@ -34,6 +33,7 @@ import os
import json
import shutil
import settings as cfg
from settings import MongoClient
# Add this helper function after imports
def ensure_timezone_aware(dt):
@@ -365,8 +365,7 @@ def cancel_ausleihung(id):
def remove_ausleihung(id):
"""
Entfernt einen Ausleihungsdatensatz aus der Datenbank.
Hinweis: Normalerweise ist es besser, Datensätze zu markieren als sie zu löschen.
Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete).
Args:
id (str): ID des zu entfernenden Ausleihungsdatensatzes
@@ -378,9 +377,17 @@ def remove_ausleihung(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
result = ausleihungen.delete_one({'_id': ObjectId(id)})
now = datetime.datetime.now()
result = ausleihungen.update_one(
{'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}},
{'$set': {
'Status': 'deleted',
'DeletedAt': now,
'LastUpdated': now,
}}
)
client.close()
return result.deleted_count > 0
return result.modified_count > 0
except Exception as e:
# print(f"Error removing ausleihung: {e}") # Log the error
return False
@@ -429,14 +436,15 @@ def get_ausleihungen(status=None, start=None, end=None, date_filter='overlap'):
collection = db['ausleihungen']
# Query erstellen
query = {}
query = {'Status': {'$ne': 'deleted'}}
# Status-Filter hinzufügen
if status is not None:
if isinstance(status, list):
query['Status'] = {'$in': status}
allowed_status = [s for s in status if s != 'deleted']
query['Status'] = {'$in': allowed_status}
else:
query['Status'] = status
query['Status'] = status if status != 'deleted' else '__blocked_deleted_status__'
# Datum parsen, wenn als String angegeben
if start is not None and isinstance(start, str):
@@ -561,7 +569,7 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
query = {'User': user_id}
query = {'User': user_id, 'Status': {'$ne': 'deleted'}}
# Wenn clientseitige Verifikation verwendet wird, holen wir ALLE Ausleihungen
# und filtern später clientseitig
@@ -610,12 +618,12 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
# Status-Matching
if isinstance(status, list):
if current_status in status:
if current_status in status and current_status != 'deleted':
# Status aktualisieren und zur Ergebnismenge hinzufügen
ausleihung['VerifiedStatus'] = current_status
filtered_results.append(ausleihung)
else:
if current_status == status:
if current_status == status and current_status != 'deleted':
# Status aktualisieren und zur Ergebnismenge hinzufügen
ausleihung['VerifiedStatus'] = current_status
filtered_results.append(ausleihung)
@@ -644,7 +652,7 @@ def get_ausleihung_by_item(item_id, status=None, include_history=False):
ausleihungen = db['ausleihungen']
# Build query
query = {'Item': item_id}
query = {'Item': item_id, 'Status': {'$ne': 'deleted'}}
if status and not include_history:
query['Status'] = status
+176
View File
@@ -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
View File
@@ -26,10 +26,10 @@ Collection Structure:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import settings as cfg
from settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
@@ -43,6 +43,14 @@ def _non_library_query(extra_query=None):
return base_query
def _active_record_query(extra_query=None):
"""Build a query that excludes logically deleted records."""
base_query = {'Deleted': {'$ne': True}}
if extra_query:
base_query.update(extra_query)
return base_query
# === ITEM MANAGEMENT ===
def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, filter3=None,
@@ -118,7 +126,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
def remove_item(id):
"""
Remove an item from the inventory.
Soft-delete an item from the inventory.
Args:
id (str): ID of the item to remove
@@ -130,9 +138,17 @@ def remove_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
result = items.delete_one({'_id': ObjectId(id)})
result = items.update_one(
{'_id': ObjectId(id), 'Deleted': {'$ne': True}},
{'$set': {
'Deleted': True,
'DeletedAt': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now(),
'Verfuegbar': False,
}}
)
client.close()
return result.deleted_count > 0
return result.modified_count > 0
except Exception as e:
print(f"Error removing item: {e}")
return False
@@ -155,7 +171,7 @@ def get_group_item_ids(id):
db = client[cfg.MONGODB_DB]
items = db['items']
base_item = items.find_one({'_id': ObjectId(id)})
base_item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
if not base_item:
client.close()
return []
@@ -165,7 +181,7 @@ def get_group_item_ids(id):
# Prefer SeriesGroupId because it represents the full logical group.
series_group_id = base_item.get('SeriesGroupId')
if series_group_id:
for group_item in items.find({'SeriesGroupId': series_group_id}, {'_id': 1}):
for group_item in items.find(_active_record_query({'SeriesGroupId': series_group_id}), {'_id': 1}):
resolved_ids.add(str(group_item['_id']))
else:
resolved_ids.add(str(base_item['_id']))
@@ -173,10 +189,10 @@ def get_group_item_ids(id):
parent_item_id = base_item.get('ParentItemId')
if parent_item_id:
resolved_ids.add(str(parent_item_id))
for sibling in items.find({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}, {'_id': 1}):
for sibling in items.find(_active_record_query({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}), {'_id': 1}):
resolved_ids.add(str(sibling['_id']))
else:
for child in items.find({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}, {'_id': 1}):
for child in items.find(_active_record_query({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}), {'_id': 1}):
resolved_ids.add(str(child['_id']))
client.close()
@@ -342,7 +358,7 @@ def is_code_unique(code_4, exclude_id=None):
items = db['items']
# Build query to find items with this code
query = {'Code_4': code_4}
query = {'Code_4': code_4, 'Deleted': {'$ne': True}}
# If we're editing an item, exclude it from the uniqueness check
if exclude_id:
@@ -368,7 +384,7 @@ def get_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find(_non_library_query())
items_return = items.find(_active_record_query(_non_library_query()))
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
@@ -391,7 +407,7 @@ def get_available_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find(_non_library_query({'Verfuegbar': True}))
items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': True})))
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
@@ -414,7 +430,7 @@ def get_borrowed_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find(_non_library_query({'Verfuegbar': False}))
items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': False})))
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
@@ -440,7 +456,7 @@ def get_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
item = items.find_one({'_id': ObjectId(id)})
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
return item
except Exception as e:
@@ -462,7 +478,7 @@ def get_item_by_name(name):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
item = items.find_one({'Name': name})
item = items.find_one(_active_record_query({'Name': name}))
client.close()
return item
except Exception as e:
@@ -486,13 +502,13 @@ def get_items_by_filter(filter_value):
items = db['items']
# Use $or to find matches in any filter field
query = _non_library_query({
query = _active_record_query(_non_library_query({
'$or': [
{'Filter': filter_value},
{'Filter2': filter_value},
{'Filter3': filter_value}
]
})
}))
results = list(items.find(query))
client.close()
@@ -518,7 +534,7 @@ def get_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
non_library = _non_library_query()
non_library = _active_record_query(_non_library_query())
filters = items.distinct('Filter', non_library)
filters2 = items.distinct('Filter2', non_library)
filters3 = items.distinct('Filter3', non_library)
@@ -550,7 +566,7 @@ def get_primary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
filters = [f for f in items.distinct('Filter', _non_library_query()) if f]
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close()
return filters
except Exception as e:
@@ -569,7 +585,7 @@ def get_secondary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
filters = [f for f in items.distinct('Filter2', _non_library_query()) if f]
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close()
return filters
except Exception as e:
@@ -588,7 +604,7 @@ def get_tertiary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
filters = [f for f in items.distinct('Filter3', _non_library_query()) if f]
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close()
return filters
except Exception as e:
@@ -610,7 +626,7 @@ def get_item_by_code_4(code_4):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
results = list(items.find(_non_library_query({"Code_4": code_4})))
results = list(items.find(_active_record_query(_non_library_query({"Code_4": code_4}))))
# Convert ObjectId to string
for item in results:
@@ -640,7 +656,14 @@ def unstuck_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
result = ausleihungen.delete_many({'Item': id})
result = ausleihungen.update_many(
{'Item': id, 'Status': {'$nin': ['cancelled', 'deleted']}},
{'$set': {
'Status': 'cancelled',
'CancelledReason': 'unstuck_reset',
'LastUpdated': datetime.datetime.now()
}}
)
# Also reset the item status
items = db['items']
@@ -980,7 +1003,7 @@ def get_items_with_appointments():
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find({'NextAppointment': {'$exists': True}})
items_return = items.find({'NextAppointment': {'$exists': True}, 'Deleted': {'$ne': True}})
items_list = []
for item in items_return:
item['_id'] = str(item['_id'])
+3 -1
View File
@@ -10,4 +10,6 @@ python-dateutil
pytz
requests
reportlab
python-barcode
python-barcode
openpyxl
cryptography
+117 -2
View File
@@ -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
+94
View File
@@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="container" style="max-width:1400px; margin:0 auto; padding:18px;">
<h1>Audit Dashboard</h1>
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 18px;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export', format='md') }}">Audit Review exportieren (Markdown)</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}">Audit Review exportieren (JSON)</a>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
{% if verify_result.ok %}
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
{% else %}
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
{% endif %}
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Einträge</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Mismatch Count</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
</div>
</div>
{% if verify_result.mismatches %}
<div style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;">
<h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3>
<div style="max-height:280px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Typ</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Expected</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Found</th>
</tr>
</thead>
<tbody>
{% for m in verify_result.mismatches %}
<tr>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.chain_index }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.error }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.expected }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.found }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div style="border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:12px;">
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
<div style="max-height:560px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Zeit</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Event</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Actor</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Hash</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Payload</th>
</tr>
</thead>
<tbody>
{% for row in audit_rows %}
<tr>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.chain_index }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.timestamp or row.created_at }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.event_type }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.actor }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace; font-size:0.8rem;">{{ row.entry_hash }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;"><pre style="margin:0; white-space:pre-wrap; font-size:0.8rem;">{{ row.payload }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+10
View File
@@ -100,6 +100,9 @@
{% if e.invoice_number %}
<div style="font-weight:600;">{{ e.invoice_number }}</div>
<div style="font-size:0.85rem; color:#666;">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %}
<div style="font-size:0.78rem; color:#7c2d12; margin-top:4px;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %}
{% if e.invoice_paid %}
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Bezahlt</div>
{% if e.invoice_paid_at %}
@@ -129,6 +132,13 @@
</button>
</form>
{% endif %}
{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');">
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px;">
<input type="text" name="amount_delta" placeholder="Delta (optional)" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger">Korrektur</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning">Zurücksetzen</button>
</form>
+121
View File
@@ -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 %}
+479 -79
View File
@@ -7,7 +7,7 @@
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!DOCTYPE html>
<html lang="en" data-module="inventory">
<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">
@@ -20,8 +20,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css', v=ASSET_VERSION) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css', v=ASSET_VERSION) }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
@@ -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,13 +446,14 @@
{% endblock %}
</head>
<body>
{% set current_path = request.path %}
<!-- Module Selector Bar -->
{% if 'username' in session %}
<div class="module-selector-bar" id="moduleBar">
<span class="module-label">Module:</span>
<div class="module-tabs">
<a href="{{ url_for('home') }}"
class="module-tab"
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
id="inventoryModule"
data-module="inventory">
📦 Inventarsystem
@@ -377,7 +461,7 @@
{% if library_module_enabled %}
<div class="module-separator"></div>
<a href="{{ url_for('library_view') }}"
class="module-tab"
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
id="libraryModule"
data-module="library">
📚 Bibliothek
@@ -386,48 +470,7 @@
</div>
</div>
{% endif %}
<script>
(function() {
// Detect current module based on URL and update data-module attribute
const currentPath = window.location.pathname;
const htmlTag = document.documentElement;
// Detect module and pages
const isLibraryModule = currentPath.includes('/library') ||
currentPath.includes('/library_admin') ||
currentPath.includes('/library_loans') ||
currentPath.includes('/student_cards');
const currentModule = isLibraryModule ? 'library' : 'inventory';
htmlTag.setAttribute('data-module', currentModule);
// Update module selector tabs
const invModule = document.getElementById('inventoryModule');
const libModule = document.getElementById('libraryModule');
if (currentModule === 'library') {
if (libModule) libModule.classList.add('active');
if (invModule) invModule.classList.remove('active');
} else {
if (invModule) invModule.classList.add('active');
if (libModule) libModule.classList.remove('active');
}
// Show/hide appropriate navbar based on current module
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentModule === 'library') {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
{% if CURRENT_MODULE != 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
@@ -437,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 %}
@@ -459,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>
@@ -474,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>
@@ -490,9 +559,10 @@
</div>
</div>
</nav>
{% endif %}
{% if library_module_enabled %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar" style="display: none;">
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('library_view') }}" data-icon="📚">Bibliothek</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNavContent">
@@ -501,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 %}
@@ -521,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>
@@ -538,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>
@@ -555,23 +650,6 @@
</div>
</nav>
{% endif %}
<script>
(function() {
// Show/hide appropriate navbar based on current module
const currentPath = window.location.pathname;
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentPath.includes('/library')) {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
@@ -653,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">
@@ -680,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){
@@ -713,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) {
+9 -8
View File
@@ -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>
+164 -34
View File
@@ -290,6 +290,20 @@
color: #666;
}
.library-load-row {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.library-load-hint {
font-size: 0.9em;
color: #6b7280;
}
/* Modal styles */
.modal {
display: none;
@@ -501,6 +515,10 @@
<!-- Items will be loaded here -->
</tbody>
</table>
<div id="loadMoreRow" class="library-load-row" style="display:none;">
<span id="loadInfo" class="library-load-hint"></span>
<button id="loadMoreBtn" class="button" type="button">Mehr laden</button>
</div>
</div>
<!-- Empty state -->
@@ -523,6 +541,24 @@
// State
let libraryItems = [];
let filteredItems = [];
let visibleItems = [];
let currentSearchTerm = '';
let activeFilters = {
author: '',
isbn: '',
type: '',
status: ''
};
let pagingState = {
offset: 0,
total: 0,
hasMore: true,
loading: false
};
const API_PAGE_SIZE = 120;
const INITIAL_RENDER_COUNT = 120;
const RENDER_BATCH_COUNT = 120;
let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false;
let scannerInstance = null;
let scannerRunning = false;
@@ -531,32 +567,124 @@
let lastScanAt = 0;
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
// Load items
async function fetchLibraryPage(offset, limit) {
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to load library items');
}
return response.json();
}
// Load items progressively: first page immediately, rest in background
async function loadLibraryItems() {
if (pagingState.loading) return;
pagingState.loading = true;
try {
const response = await fetch('/api/library_items');
if (!response.ok) throw new Error('Failed to load library items');
libraryItems = await response.json();
displayItems(libraryItems);
const firstPage = await fetchLibraryPage(0, API_PAGE_SIZE);
libraryItems = firstPage.items || [];
pagingState.offset = (firstPage.offset || 0) + (firstPage.count || libraryItems.length);
pagingState.total = firstPage.total || libraryItems.length;
pagingState.hasMore = Boolean(firstPage.has_more);
applyFiltersAndSearch(true);
if (pagingState.hasMore) {
loadRemainingLibraryItemsInBackground();
}
} catch (error) {
console.error('Error loading library items:', error);
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
} finally {
pagingState.loading = false;
}
}
// Display items in table
function displayItems(items) {
async function loadRemainingLibraryItemsInBackground() {
while (pagingState.hasMore) {
try {
const page = await fetchLibraryPage(pagingState.offset, API_PAGE_SIZE);
const nextItems = page.items || [];
if (nextItems.length === 0) {
pagingState.hasMore = false;
break;
}
libraryItems = libraryItems.concat(nextItems);
pagingState.offset = (page.offset || pagingState.offset) + (page.count || nextItems.length);
pagingState.total = page.total || pagingState.total;
pagingState.hasMore = Boolean(page.has_more);
// Keep the current view reactive while avoiding a full rerender burst.
applyFiltersAndSearch(false);
if ('requestIdleCallback' in window) {
await new Promise(resolve => requestIdleCallback(() => resolve(), { timeout: 250 }));
} else {
await new Promise(resolve => setTimeout(resolve, 0));
}
} catch (error) {
console.error('Error loading additional library items:', error);
break;
}
}
}
function filterItems(items) {
const author = activeFilters.author;
const isbn = activeFilters.isbn;
const type = activeFilters.type;
const status = activeFilters.status;
return items.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch;
});
}
function applySearch(items) {
if (!currentSearchTerm) return items;
return items.filter(item =>
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
);
}
function applyFiltersAndSearch(resetRenderCount) {
const filteredByPanel = filterItems(libraryItems);
filteredItems = applySearch(filteredByPanel);
if (resetRenderCount) {
renderedCount = Math.min(INITIAL_RENDER_COUNT, filteredItems.length);
} else {
renderedCount = Math.min(Math.max(renderedCount, INITIAL_RENDER_COUNT), filteredItems.length || INITIAL_RENDER_COUNT);
}
visibleItems = filteredItems;
renderItems();
}
// Display current view with incremental rendering
function renderItems() {
const tbody = document.getElementById('itemsTableBody');
const emptyState = document.getElementById('emptyState');
const loadMoreRow = document.getElementById('loadMoreRow');
const loadInfo = document.getElementById('loadInfo');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (items.length === 0) {
if (visibleItems.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
loadMoreRow.style.display = 'none';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = items.map(item => `
const rowsToRender = visibleItems.slice(0, renderedCount);
tbody.innerHTML = rowsToRender.map(item => `
${(() => {
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
@@ -567,7 +695,7 @@
<tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code4 || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td>
<span class="table-status ${statusClass}">
@@ -585,6 +713,18 @@
`;
})()}
`).join('');
const canLoadMoreRows = renderedCount < visibleItems.length;
if (canLoadMoreRows) {
loadMoreRow.style.display = 'flex';
loadMoreBtn.style.display = 'inline-block';
loadInfo.textContent = `${renderedCount} von ${visibleItems.length} angezeigt`;
} else {
const backgroundHint = pagingState.hasMore ? `Lade weitere Medien im Hintergrund (${libraryItems.length}/${pagingState.total || '?'})...` : `${visibleItems.length} Treffer`;
loadMoreRow.style.display = 'flex';
loadMoreBtn.style.display = 'none';
loadInfo.textContent = backgroundHint;
}
}
// Filter toggle
@@ -598,21 +738,11 @@
// Apply filters
document.getElementById('applyFilterBtn').addEventListener('click', () => {
const author = document.getElementById('filterAuthor').value.toLowerCase();
const isbn = document.getElementById('filterISBN').value.toLowerCase();
const type = document.getElementById('filterType').value;
const status = document.getElementById('filterStatus').value;
filteredItems = libraryItems.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch;
});
displayItems(filteredItems);
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
activeFilters.type = document.getElementById('filterType').value;
activeFilters.status = document.getElementById('filterStatus').value;
applyFiltersAndSearch(true);
});
// Clear filters
@@ -621,19 +751,19 @@
document.getElementById('filterISBN').value = '';
document.getElementById('filterType').value = '';
document.getElementById('filterStatus').value = '';
displayItems(libraryItems);
activeFilters = { author: '', isbn: '', type: '', status: '' };
applyFiltersAndSearch(true);
});
// Search input
document.getElementById('librarySearch').addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const toSearch = filteredItems.length > 0 ? filteredItems : libraryItems;
const results = toSearch.filter(item =>
(item.Name || '').toLowerCase().includes(searchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(searchTerm) ||
(item.ISBN || item.Code4 || '').toLowerCase().includes(searchTerm)
);
displayItems(results);
currentSearchTerm = (e.target.value || '').toLowerCase();
applyFiltersAndSearch(true);
});
document.getElementById('loadMoreBtn').addEventListener('click', () => {
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
renderItems();
});
// Helper functions
-5
View File
@@ -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 -->
+276 -9
View File
@@ -724,8 +724,75 @@
// Add this line to define allItems globally
let allItems = [];
function loadItems() {
fetch("{{ url_for('get_items') }}")
const MAIN_ITEMS_PAGE_SIZE = 120;
let mainItemsNextOffset = 0;
let mainItemsHasMore = false;
let mainItemsLoadingMore = false;
let mainItemsLightMode = true; // Track if we're in light mode to gradually request full data
let mainItemsObserver = null;
let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false;
function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator';
mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainItemsLoadingIndicator);
return itemsContainer;
}
function updateMainItemsLoadingIndicator() {
const itemsContainer = ensureMainItemsLoadingIndicator();
if (!itemsContainer || !mainItemsLoadingIndicator) return;
const shouldShow = mainItemsHasMore || mainItemsLoadingMore;
mainItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainItemsLoadingIndicator.classList.toggle('is-loading', mainItemsLoadingMore);
const label = mainItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
function maybePrefetchMainItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainItemsHasMore || mainItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -735,27 +802,39 @@
const filter3Values = new Set();
// Store all items data globally for filter rebuilding
allItems = data.items || [];
const pageItems = data.items || [];
allItems = append ? allItems.concat(pageItems) : pageItems;
// Clear the container first
itemsContainer.innerHTML = '';
if (!append) {
itemsContainer.innerHTML = '';
}
if (!data.items || data.items.length === 0) {
if (!append && (!pageItems || pageItems.length === 0)) {
itemsContainer.innerHTML = '<div class="no-items-message">Keine Objekte gefunden</div>';
return;
}
// Sort items by name in ascending order (A-Z)
data.items.sort((a, b) => {
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = b.Name ? b.Name.toLowerCase() : '';
return nameA.localeCompare(nameB); // Ascending order
});
allItems.forEach(itemForFilters => {
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
});
const favoriteIds = new Set(data.favorites || []);
window.currentFavorites = favoriteIds;
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
data.items.forEach(item => {
pageItems.forEach(item => {
try {
const card = document.createElement('div');
card.classList.add('item-card');
@@ -995,8 +1074,10 @@
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Start display from leftmost position (first card, which is now Z)
itemsContainer.scrollLeft = 0;
// Start display from leftmost position on full reload only.
if (!append) {
itemsContainer.scrollLeft = 0;
}
// Apply filters
applyFilters();
@@ -1011,15 +1092,93 @@
if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) {
rebuildFilter3Options();
}
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainItemsHasMore = Boolean(data.has_more);
updateMainItemsLoadingIndicator();
setupMainItemsLazyLoading();
maybePrefetchMainItems();
})
.catch(error => {
console.error('Error fetching items:', error);
mainItemsLoadingMore = false;
mainItemsHasMore = false;
updateMainItemsLoadingIndicator();
if (append) {
return;
}
const itemsContainer = document.querySelector('#items-container');
itemsContainer.innerHTML =
'<div class="error-message">Fehler beim Laden der Objekte. Bitte versuchen Sie es später erneut.</div>';
});
}
function ensureMainItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainItemsLoadingIndicator();
if (!mainItemsSentinel) {
mainItemsSentinel = document.createElement('div');
mainItemsSentinel.id = 'main-items-sentinel';
mainItemsSentinel.style.flex = '0 0 1px';
mainItemsSentinel.style.width = '1px';
mainItemsSentinel.style.minWidth = '1px';
mainItemsSentinel.style.height = '1px';
mainItemsSentinel.style.pointerEvents = 'none';
}
if (!mainItemsSentinel.parentNode) {
itemsContainer.appendChild(mainItemsSentinel);
} else {
itemsContainer.appendChild(mainItemsSentinel);
}
return itemsContainer;
}
function setupMainItemsLazyLoading() {
const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return;
if (mainItemsObserver) {
mainItemsObserver.disconnect();
mainItemsObserver = null;
}
if (!mainItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
mainItemsScrollPrefetchBound = true;
}
if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainItemsLoadingMore || !mainItemsHasMore) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
updateMainItemsLoadingIndicator();
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
@@ -1923,6 +2082,42 @@
applyFilters();
}
function bulkToggleFilterOptions(filterNum, shouldSelect) {
const filterKey = `filter${filterNum}`;
const checkboxes = Array.from(document.querySelectorAll(`#filter${filterNum}-options input[type="checkbox"]`));
if (checkboxes.length === 0) {
return;
}
const nextValues = new Set(activeFilters[filterKey]);
checkboxes.forEach(checkbox => {
const value = checkbox.value;
const group = checkbox.dataset.group || '';
const filterValue = group ? `${group}:${value}` : value;
checkbox.checked = shouldSelect;
if (shouldSelect) {
nextValues.add(filterValue);
} else {
nextValues.delete(filterValue);
}
});
activeFilters[filterKey] = Array.from(nextValues);
updateSelectedFilters(filterNum);
if (filterNum === 1) {
rebuildFilter2Options();
rebuildFilter3Options();
} else if (filterNum === 2) {
rebuildFilter3Options();
}
saveFilterState();
applyFilters();
}
function populateFilterOptions(containerId, filterValues, filterNum) {
const container = document.getElementById(containerId);
if (!container) {
@@ -1932,6 +2127,27 @@
container.innerHTML = ''; // Clear previous options
const bulkActions = document.createElement('div');
bulkActions.style.display = 'flex';
bulkActions.style.gap = '8px';
bulkActions.style.marginBottom = '10px';
const selectAllBtn = document.createElement('button');
selectAllBtn.type = 'button';
selectAllBtn.className = 'clear-filter';
selectAllBtn.textContent = 'Alle wählen';
selectAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, true);
const clearAllBtn = document.createElement('button');
clearAllBtn.type = 'button';
clearAllBtn.className = 'clear-filter';
clearAllBtn.textContent = 'Alle abwählen';
clearAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, false);
bulkActions.appendChild(selectAllBtn);
bulkActions.appendChild(clearAllBtn);
container.appendChild(bulkActions);
// First group values by category/prefix if they exist
const groupedValues = {};
@@ -2680,6 +2896,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
+631 -25
View File
@@ -26,6 +26,106 @@
font-weight: 600;
}
.bulk-delete-inline-wrap {
display: inline-flex;
}
.bulk-delete-drawer-toggle {
position: static;
transform: none;
z-index: 2;
}
.bulk-delete-drawer-toggle .drawer-icon {
display: inline-block;
margin-right: 4px;
}
.bulk-delete-drawer-toggle:hover {
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
}
.bulk-delete-drawer-toggle:active {
transform: scale(0.98);
}
.bulk-delete-drawer {
display: none;
width: 100%;
max-width: 980px;
margin: 0 auto 18px;
border: 1px solid #dbe3ee;
background: #ffffff;
border-radius: 10px;
padding: 14px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.bulk-delete-drawer.open {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 14px;
}
.bulk-delete-content {
flex: 1 1 380px;
}
.bulk-delete-drawer strong {
display: block;
margin-bottom: 2px;
}
.bulk-delete-summary {
color: #334155;
font-size: 0.92rem;
margin-top: 4px;
}
.bulk-delete-drawer small {
color: #64748b;
display: block;
margin-top: 2px;
line-height: 1.35;
}
.bulk-delete-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
align-items: center;
flex: 0 1 auto;
}
.bulk-delete-actions button {
border: 1px solid #dbe3ee;
border-radius: 8px;
padding: 9px 12px;
cursor: pointer;
background: #f8fafc;
color: #1e293b;
font-weight: 600;
text-align: center;
}
.bulk-delete-actions button:hover {
background: #eef4ff;
border-color: #bfdbfe;
}
.bulk-delete-actions .danger {
background: #dc3545;
color: #fff;
border-color: #c82333;
}
.bulk-delete-actions .danger:hover {
background: #c82333;
border-color: #b21f2d;
}
/* Filter styles */
.filter-container {
display: flex;
@@ -223,6 +323,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
@@ -237,6 +388,12 @@
position: relative;
}
.item-card.bulk-selected {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.18), 0 0 10px rgba(0, 0, 0, 0.1);
background: #fffdfd;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
@@ -257,6 +414,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) {
@@ -2847,6 +3199,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
const allOption = document.createElement('option');
allOption.value = '__ALL__';
allOption.textContent = '-- Alle auswählen --';
select.appendChild(allOption);
// Add new options - data.values contains the array
data.values.forEach(filter => {
@@ -2958,8 +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');
@@ -2969,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>
@@ -2984,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');
@@ -3143,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 ? `
@@ -3218,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();
@@ -3232,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 =
@@ -3245,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;
@@ -3339,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)) {
@@ -3528,7 +4049,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Display existing images
const existingImagesContainer = document.getElementById('edit-existing-images');
const editForm = document.getElementById('edit-item-form');
existingImagesContainer.innerHTML = '';
if (editForm) {
editForm.querySelectorAll('input[name="existing_images"], input[name="removed_images"]').forEach(input => input.remove());
}
if (item.Images && Array.isArray(item.Images)) {
item.Images.forEach((image, index) => {
const isVideo = isVideoFile(image);
@@ -3543,22 +4068,46 @@ document.addEventListener('DOMContentLoaded', ()=>{
image :
`{{ url_for('uploaded_file', filename='') }}${image}`);
const row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '8px';
row.style.alignItems = 'center';
if (isVideo) {
imageDiv.innerHTML = `
<div style="display:flex; gap:8px; align-items:center;">
<video src="${imageSrc}" style="max-width:100px; max-height:100px; object-fit:contain;" controls></video>
<button type="button" class="delete-image-button" onclick="deleteExistingImage('${item._id}', ${index})">Löschen</button>
</div>
`;
const video = document.createElement('video');
video.src = imageSrc;
video.style.maxWidth = '100px';
video.style.maxHeight = '100px';
video.style.objectFit = 'contain';
video.controls = true;
row.appendChild(video);
} else {
imageDiv.innerHTML = `
<div style="display:flex; gap:8px; align-items:center;">
<img src="${imageSrc}" style="max-width:100px; max-height:100px; object-fit:contain;" alt="Existierendes Bild ${index + 1}">
<button type="button" class="delete-image-button" onclick="deleteExistingImage('${item._id}', ${index})">Löschen</button>
</div>
`;
const img = document.createElement('img');
img.src = imageSrc;
img.style.maxWidth = '100px';
img.style.maxHeight = '100px';
img.style.objectFit = 'contain';
img.alt = `Existierendes Bild ${index + 1}`;
row.appendChild(img);
}
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-image-button';
deleteButton.textContent = 'Löschen';
deleteButton.addEventListener('click', () => removeExistingImage(image, deleteButton));
row.appendChild(deleteButton);
imageDiv.appendChild(row);
existingImagesContainer.appendChild(imageDiv);
if (editForm) {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = image;
editForm.appendChild(hiddenInput);
}
});
}
@@ -4401,6 +4950,42 @@ document.addEventListener('DOMContentLoaded', ()=>{
applyFilters();
}
function bulkToggleFilterOptions(filterNum, shouldSelect) {
const filterKey = `filter${filterNum}`;
const checkboxes = Array.from(document.querySelectorAll(`#filter${filterNum}-options input[type="checkbox"]`));
if (checkboxes.length === 0) {
return;
}
const nextValues = new Set(activeFilters[filterKey]);
checkboxes.forEach(checkbox => {
const value = checkbox.value;
const group = checkbox.dataset.group || '';
const filterValue = group ? `${group}:${value}` : value;
checkbox.checked = shouldSelect;
if (shouldSelect) {
nextValues.add(filterValue);
} else {
nextValues.delete(filterValue);
}
});
activeFilters[filterKey] = Array.from(nextValues);
updateSelectedFiltersDisplay(filterNum);
if (filterNum === 1) {
rebuildFilter2Options();
rebuildFilter3Options();
} else if (filterNum === 2) {
rebuildFilter3Options();
}
saveFilterState();
applyFilters();
}
function populateFilterOptions(containerId, filterValues, filterNum) {
const container = document.getElementById(containerId);
if (!container) {
@@ -4408,6 +4993,27 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
container.innerHTML = '';
const bulkActions = document.createElement('div');
bulkActions.style.display = 'flex';
bulkActions.style.gap = '8px';
bulkActions.style.marginBottom = '10px';
const selectAllBtn = document.createElement('button');
selectAllBtn.type = 'button';
selectAllBtn.className = 'clear-filter';
selectAllBtn.textContent = 'Alle wählen';
selectAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, true);
const clearAllBtn = document.createElement('button');
clearAllBtn.type = 'button';
clearAllBtn.className = 'clear-filter';
clearAllBtn.textContent = 'Alle abwählen';
clearAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, false);
bulkActions.appendChild(selectAllBtn);
bulkActions.appendChild(clearAllBtn);
container.appendChild(bulkActions);
const groupedValues = {};
+86
View File
@@ -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>
+3 -3
View File
@@ -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 %}
+30
View File
@@ -710,6 +710,28 @@
<div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
</form>
</div>
{% else %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form>
</div>
{% endif %}
<div class="upload-form">
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
@@ -1114,9 +1136,17 @@
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
const allOption = document.createElement('option');
allOption.value = '__ALL__';
allOption.textContent = '-- Alle auswählen --';
select.appendChild(allOption);
// Add new options - data.values contains the array
data.values.forEach(value => {
if (!value || String(value).trim() === '') {
return;
}
const option = document.createElement('option');
option.value = value;
option.textContent = value;
+1 -1
View File
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
import hashlib
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
def normalize_student_card_id(card_id):
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""CLI utility to verify the tamper-evident audit chain."""
import json
import sys
from pymongo import MongoClient
import settings as cfg
import audit_log as al
def main():
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0 if result.get("ok") else 2
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
+7 -1
View File
@@ -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,16 +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:
- ./Web:/app/Web
- ./config.json:/app/config.json:ro
- ./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:
@@ -58,3 +63,4 @@ volumes:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
+113 -18
View File
@@ -1,9 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALLER_SOURCE="${BASH_SOURCE[0]-$0}"
if [ -n "$INSTALLER_SOURCE" ] && [ "$INSTALLER_SOURCE" != "bash" ] && [ "$INSTALLER_SOURCE" != "-bash" ] && [ "$INSTALLER_SOURCE" != "sh" ] && [ "$INSTALLER_SOURCE" != "-" ] && [ -e "$INSTALLER_SOURCE" ]; then
INSTALLER_DIR="$(cd "$(dirname "$INSTALLER_SOURCE")" && pwd)"
else
INSTALLER_DIR="$(pwd)"
fi
PROJECT_DIR="/opt/Inventarsystem"
REPO_SLUG="AIIrondev/Inventarsystem"
REPO_SLUG="AIIrondev/legendary-octo-garbanzo"
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
@@ -16,6 +21,13 @@ LEGACY_SYSTEM_DIR=""
LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR=""
cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
}
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -24,6 +36,83 @@ need_cmd() {
fi
}
refresh_start_script_from_main() {
local start_url
start_url="https://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
if curl -fsSL "$start_url" -o "$TMP_DIR/start.sh"; then
sudo install -m 755 "$TMP_DIR/start.sh" "$PROJECT_DIR/start.sh"
else
echo "Warning: failed to refresh start.sh from main; using bundled start.sh"
fi
}
pin_compose_app_image() {
local tag="$1"
local compose_file
compose_file="$PROJECT_DIR/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Warning: $compose_file not found; cannot pin app image"
return 0
fi
python3 - <<'PY' "$compose_file" "$tag"
import re
import sys
compose_file, tag = sys.argv[1], sys.argv[2]
target_image = f"ghcr.io/aiirondev/legendary-octo-garbanzo:{tag}"
with open(compose_file, "r", encoding="utf-8") as f:
lines = f.readlines()
out = []
in_app = False
in_build = False
image_set = False
for line in lines:
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
if not in_app and re.match(r"^\s{2}app:\s*$", line):
in_app = True
image_set = False
out.append(line)
out.append(f" image: {target_image}\n")
image_set = True
continue
if in_app:
if indent == 2 and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
in_app = False
in_build = False
if in_app:
if in_build:
if indent > 4:
continue
in_build = False
if re.match(r"^\s{4}build:\s*$", line):
in_build = True
continue
if re.match(r"^\s{4}image:\s*", line):
if image_set:
continue
out.append(f" image: {target_image}\n")
image_set = True
continue
out.append(line)
with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out)
PY
}
install_docker_if_missing() {
if command -v docker >/dev/null 2>&1; then
return 0
@@ -258,10 +347,10 @@ main() {
need_cmd python3
need_cmd curl
local tmp_dir meta_file tag bundle_url image_url
tmp_dir="$(mktemp -d)"
meta_file="$tmp_dir/release.json"
trap 'rm -rf "$tmp_dir"' EXIT
local meta_file tag bundle_url image_url
TMP_DIR="$(mktemp -d)"
meta_file="$TMP_DIR/release.json"
trap cleanup_tmp_dir EXIT
mapfile -t release_info < <(latest_tag_and_bundle_url "$meta_file")
tag="${release_info[0]:-}"
@@ -277,16 +366,20 @@ main() {
echo "Installing Inventarsystem release $tag into $PROJECT_DIR"
sudo mkdir -p "$PROJECT_DIR"
curl -fL "$bundle_url" -o "$tmp_dir/$BUNDLE_ASSET"
sudo tar -xzf "$tmp_dir/$BUNDLE_ASSET" -C "$PROJECT_DIR"
curl -fL "$bundle_url" -o "$TMP_DIR/$BUNDLE_ASSET"
sudo tar -xzf "$TMP_DIR/$BUNDLE_ASSET" -C "$PROJECT_DIR"
pin_compose_app_image "$tag"
if [ -z "$image_url" ]; then
echo "Error: release image asset is missing"
exit 1
fi
curl -fL "$image_url" -o "$tmp_dir/inventarsystem-image-$tag.tar.gz"
sudo docker load -i "$tmp_dir/inventarsystem-image-$tag.tar.gz" >/dev/null
curl -fL "$image_url" -o "$TMP_DIR/inventarsystem-image-$tag.tar.gz"
sudo docker load -i "$TMP_DIR/inventarsystem-image-$tag.tar.gz" >/dev/null
# Compatibility alias: some legacy compose bundles may still reference :latest.
sudo docker tag "ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" "ghcr.io/aiirondev/legendary-octo-garbanzo:latest" >/dev/null 2>&1 || true
if [ ! -f "$PROJECT_DIR/start.sh" ]; then
echo "Error: release bundle is missing start.sh"
@@ -297,16 +390,18 @@ main() {
exit 1
fi
if [ ! -f "$PROJECT_DIR/restart.sh" ]; then
cat > "$tmp_dir/restart.sh" <<'EOF'
cat > "$TMP_DIR/restart.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$SCRIPT_DIR/stop.sh"
"$SCRIPT_DIR/start.sh"
EOF
sudo install -m 755 "$tmp_dir/restart.sh" "$PROJECT_DIR/restart.sh"
sudo install -m 755 "$TMP_DIR/restart.sh" "$PROJECT_DIR/restart.sh"
fi
refresh_start_script_from_main
if [ ! -f "$PROJECT_DIR/cleanup-old.sh" ] && [ -f "$INSTALLER_DIR/cleanup-old.sh" ]; then
sudo install -m 755 "$INSTALLER_DIR/cleanup-old.sh" "$PROJECT_DIR/cleanup-old.sh"
fi
@@ -317,23 +412,23 @@ EOF
echo "$tag" | sudo tee "$PROJECT_DIR/.release-version" >/dev/null
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$tmp_dir/.docker-build.env" <<EOF
cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:$tag
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
EOF
sudo install -m 644 "$tmp_dir/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
elif sudo grep -q '^INVENTAR_APP_IMAGE=' "$PROJECT_DIR/.docker-build.env"; then
sudo sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:$tag|" "$PROJECT_DIR/.docker-build.env"
sudo sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag|" "$PROJECT_DIR/.docker-build.env"
else
echo "INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:$tag" | sudo tee -a "$PROJECT_DIR/.docker-build.env" >/dev/null
echo "INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" | sudo tee -a "$PROJECT_DIR/.docker-build.env" >/dev/null
fi
backup_legacy_database
echo "Starting stack..."
sudo bash "$PROJECT_DIR/start.sh"
sudo env INVENTAR_APP_IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" bash "$PROJECT_DIR/start.sh"
restore_legacy_backup_into_docker
cleanup_old_services
+3 -1
View File
@@ -9,4 +9,6 @@ python-dateutil
pytz
requests
reportlab
python-barcode
python-barcode
openpyxl
cryptography
+45 -5
View File
@@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/inventarsystem"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist"
SUDO=""
@@ -17,7 +17,7 @@ NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="80"
HTTPS_PORT_VALUE="443"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/inventarsystem:latest}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
usage() {
cat <<EOF
@@ -290,6 +290,44 @@ EOF
fi
}
ensure_runtime_config_json() {
local config_path backup_path
config_path="$SCRIPT_DIR/config.json"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
{
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 443,
"mongodb": {
"host": "mongodb",
"port": 27017,
"db": "Inventarsystem"
},
"modules": {
"library": {
"enabled": false
},
"student_cards": {
"enabled": false,
"default_borrow_days": 14,
"max_borrow_days": 365
}
}
}
EOF
echo "Created default runtime config at $config_path"
fi
}
ensure_app_image_loaded() {
if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
return 0
@@ -364,11 +402,12 @@ configure_nuitka_mode() {
}
resolve_app_image() {
local env_image="" release_tag=""
local env_image="" release_tag="" default_latest
default_latest="$APP_IMAGE_REPO:latest"
if [ -f "$ENV_FILE" ]; then
env_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2}' "$ENV_FILE" | tail -n1 | tr -d ' ' || true)"
if [ -n "$env_image" ] && [ "$env_image" != "ghcr.io/aiirondev/inventarsystem:latest" ]; then
if [ -n "$env_image" ] && [ "$env_image" != "$default_latest" ]; then
APP_IMAGE_VALUE="$env_image"
return 0
fi
@@ -379,7 +418,7 @@ resolve_app_image() {
fi
if [ -n "$release_tag" ]; then
APP_IMAGE_VALUE="ghcr.io/aiirondev/inventarsystem:$release_tag"
APP_IMAGE_VALUE="$APP_IMAGE_REPO:$release_tag"
return 0
fi
@@ -557,6 +596,7 @@ ensure_runtime_dependencies
setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json
setup_scheduled_jobs
configure_nuitka_mode
resolve_app_image
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26 -2
View File
@@ -8,12 +8,12 @@ PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$PROJECT_DIR/logs"
LOG_FILE="$LOG_DIR/update.log"
STATE_FILE="$PROJECT_DIR/.release-version"
REPO_SLUG="AIIrondev/Inventarsystem"
REPO_SLUG="AIIrondev/legendary-octo-garbanzo"
API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/inventarsystem"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$PROJECT_DIR/dist"
mkdir -p "$LOG_DIR"
@@ -234,10 +234,26 @@ load_release_image() {
log_message "Loading app image from release asset $image_asset"
curl -fL "$image_url" -o "$archive"
docker load -i "$archive" >> "$LOG_FILE" 2>&1
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
trap - RETURN
}
refresh_runtime_scripts_from_main() {
local start_url stop_url restart_url update_url
start_url="https://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
stop_url="https://raw.githubusercontent.com/$REPO_SLUG/main/stop.sh"
restart_url="https://raw.githubusercontent.com/$REPO_SLUG/main/restart.sh"
update_url="https://raw.githubusercontent.com/$REPO_SLUG/main/update.sh"
curl -fsSL "$start_url" -o "$PROJECT_DIR/start.sh" || log_message "WARNING: Could not refresh start.sh from main"
curl -fsSL "$stop_url" -o "$PROJECT_DIR/stop.sh" || log_message "WARNING: Could not refresh stop.sh from main"
curl -fsSL "$restart_url" -o "$PROJECT_DIR/restart.sh" || log_message "WARNING: Could not refresh restart.sh from main"
curl -fsSL "$update_url" -o "$PROJECT_DIR/update.sh" || log_message "WARNING: Could not refresh update.sh from main"
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh"
}
find_local_dist_image_archive() {
local tag="$1"
local archive
@@ -277,6 +293,7 @@ load_local_dist_image() {
log_message "Loading app image from local dist artifact: $archive"
if docker load -i "$archive" >> "$LOG_FILE" 2>&1; then
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
return 0
fi
@@ -308,6 +325,11 @@ download_and_extract_bundle() {
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
fi
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
log_message "Installed default config.json from release bundle"
fi
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh"
}
@@ -342,6 +364,7 @@ EOF
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
}
verify_stack_health() {
@@ -460,6 +483,7 @@ main() {
log_message "Updating from release $latest_tag"
download_and_extract_bundle "$bundle_url" "$tmp_dir"
refresh_runtime_scripts_from_main
deploy "$latest_tag" "$meta_file"
if ! verify_stack_health; then
log_message "ERROR: Updated stack failed health check"