Compare commits

...

20 Commits

Author SHA1 Message Date
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
Aiirondev_dev 71026b8eab Update Docker release workflow and correct version in config 2026-04-10 15:09:33 +02:00
24 changed files with 1548 additions and 175 deletions
+7 -2
View File
@@ -19,6 +19,9 @@ permissions:
contents: write
packages: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
release-docker:
runs-on: ubuntu-latest
@@ -200,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
@@ -221,11 +225,12 @@ jobs:
cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
cp backup-docker.sh release-bundle/backup-docker.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
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup-docker.sh release-bundle/update.sh release-bundle/init-admin.sh
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/init-admin.sh
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release
+2 -1
View File
@@ -1,4 +1,5 @@
dist
logs
certs
build
build
.venv
+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
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
+632 -32
View File
@@ -30,6 +30,7 @@ from werkzeug.utils import secure_filename
import user as us
import items as it
import ausleihung as au
import audit_log as al
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from pymongo import MongoClient
@@ -92,6 +93,152 @@ SCHOOL_PERIODS = cfg.SCHOOL_PERIODS
# Apply the configuration for general use throughout the app
APP_VERSION = __version__
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
if env_version:
return env_version
try:
if os.path.exists(RELEASE_STATE_FILE):
with open(RELEASE_STATE_FILE, 'r', encoding='utf-8') as f:
release_tag = f.read().strip()
if release_tag:
return release_tag
except Exception:
pass
return APP_VERSION
def _is_library_module_path(path):
"""Return True when the current request path belongs to the library module."""
if not path:
return False
library_prefixes = (
'/library',
'/library_',
'/student_cards',
)
return path.startswith(library_prefixes)
def _get_current_module(path):
"""Resolve the active UI module for navbar separation."""
if cfg.LIBRARY_MODULE_ENABLED and _is_library_module_path(path):
return 'library'
return 'inventory'
def _append_audit_event(db, event_type, payload):
"""Write an audit entry; never break business flow on audit failures."""
try:
al.append_audit_event(
db=db,
event_type=event_type,
actor=session.get('username', 'system'),
payload=payload,
request_ip=request.remote_addr,
source='web',
)
except Exception as exc:
app.logger.warning(f"Audit write failed for {event_type}: {exc}")
_AUDIT_INDEXES_READY = False
def _ensure_audit_indexes_once():
"""Ensure audit indexes exist once per process."""
global _AUDIT_INDEXES_READY
if _AUDIT_INDEXES_READY:
return
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
_AUDIT_INDEXES_READY = True
except Exception as exc:
app.logger.warning(f"Could not ensure audit indexes: {exc}")
finally:
if client:
client.close()
def _append_audit_event_standalone(event_type, payload):
"""Write audit event by opening a short-lived DB connection."""
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
_append_audit_event(db, event_type, payload)
except Exception as exc:
app.logger.warning(f"Standalone audit write failed for {event_type}: {exc}")
finally:
if client:
client.close()
def _build_audit_review_markdown(verify_result, event_counts, audit_rows, generated_at=None):
"""Build a detailed, downloadable markdown review for audit status and events."""
ts = generated_at or datetime.datetime.utcnow().isoformat() + 'Z'
lines = []
lines.append('# Audit Review Export')
lines.append('')
lines.append(f'- Generated at: {ts}')
lines.append(f"- Chain status: {'OK' if verify_result.get('ok') else 'ERROR'}")
lines.append(f"- Total entries: {verify_result.get('count', 0)}")
lines.append(f"- Last chain index: {verify_result.get('last_chain_index', 0)}")
lines.append(f"- Last hash: {verify_result.get('last_hash', '')}")
lines.append(f"- Mismatch count: {len(verify_result.get('mismatches', []) or [])}")
lines.append('')
lines.append('## Event Type Distribution')
lines.append('')
if event_counts:
for item in event_counts:
lines.append(f"- {item.get('event_type', 'unknown')}: {item.get('count', 0)}")
else:
lines.append('- No events available')
lines.append('')
mismatches = verify_result.get('mismatches', []) or []
lines.append('## Chain Mismatches')
lines.append('')
if mismatches:
for mismatch in mismatches:
lines.append(
f"- index={mismatch.get('chain_index')} | error={mismatch.get('error')} | expected={mismatch.get('expected')} | found={mismatch.get('found')}"
)
else:
lines.append('- None')
lines.append('')
lines.append('## Recent Events')
lines.append('')
for row in audit_rows:
lines.append(f"### Event #{row.get('chain_index')}")
lines.append(f"- Timestamp: {row.get('timestamp') or row.get('created_at')}")
lines.append(f"- Type: {row.get('event_type', '')}")
lines.append(f"- Actor: {row.get('actor', '')}")
lines.append(f"- Source: {row.get('source', '')}")
lines.append(f"- IP: {row.get('ip', '')}")
lines.append(f"- Entry hash: {row.get('entry_hash', '')}")
lines.append(f"- Prev hash: {row.get('prev_hash', '')}")
payload_json = json.dumps(row.get('payload', {}), ensure_ascii=False, sort_keys=True, indent=2, default=str)
lines.append('- Payload:')
lines.append('```json')
lines.append(payload_json)
lines.append('```')
lines.append('')
return '\n'.join(lines)
def _parse_money_value(value):
@@ -293,13 +440,19 @@ def _prepare_invoice_pdf_payload(invoice_data, borrow_doc=None, item_doc=None):
def inject_version():
"""Inject global template variables."""
is_admin = False
asset_version = _get_asset_version()
if 'username' in session:
try:
is_admin = us.check_admin(session['username'])
except Exception:
is_admin = False
current_module = _get_current_module(request.path)
return {
'APP_VERSION': APP_VERSION,
'ASSET_VERSION': asset_version,
'CURRENT_MODULE': current_module,
'school_periods': SCHOOL_PERIODS,
'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED,
'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED,
@@ -668,6 +821,42 @@ def sanitize_form_value(value):
return value
FILTER_SELECT_ALL_TOKEN = '__ALL__'
def expand_filter_selection(selected_values, filter_num):
"""Expand special filter token into all predefined values for a filter."""
if not isinstance(selected_values, list):
return []
has_select_all = False
cleaned_values = []
seen = set()
for raw_value in selected_values:
value = str(raw_value).strip() if raw_value is not None else ''
if not value:
continue
if value == FILTER_SELECT_ALL_TOKEN:
has_select_all = True
continue
if value not in seen:
cleaned_values.append(value)
seen.add(value)
if not has_select_all:
return cleaned_values
predefined_values = it.get_predefined_filter_values(filter_num)
for predefined_value in predefined_values:
value = str(predefined_value).strip() if predefined_value is not None else ''
if value and value not in seen:
cleaned_values.append(value)
seen.add(value)
return cleaned_values
def normalize_isbn(isbn_raw):
"""Return only digits/X from ISBN input in canonical uppercase form."""
if isbn_raw is None:
@@ -1053,6 +1242,8 @@ def library_loans_admin():
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
_ensure_audit_indexes_once()
def fmt_dt(dt):
try:
return dt.strftime('%d.%m.%Y %H:%M') if dt else ''
@@ -1070,7 +1261,7 @@ def library_loans_admin():
ausleihungen_col = db['ausleihungen']
library_items = list(items_col.find(
{'ItemType': {'$in': LIBRARY_ITEM_TYPES}},
{'ItemType': {'$in': LIBRARY_ITEM_TYPES}, 'Deleted': {'$ne': True}},
{'Name': 1, 'Code_4': 1, 'Anschaffungskosten': 1, 'Condition': 1, 'HasDamage': 1, 'DamageReports': 1, 'Verfuegbar': 1, 'User': 1, 'ItemType': 1, 'Author': 1, 'ISBN': 1}
))
item_map = {str(item['_id']): item for item in library_items if item.get('_id')}
@@ -1116,6 +1307,7 @@ def library_loans_admin():
'invoice_amount': fmt_money(invoice_data.get('amount')) if invoice_data.get('amount') is not None else fmt_money(item_doc.get('Anschaffungskosten')),
'invoice_paid': bool(invoice_data.get('paid', False)),
'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '',
'invoice_corrections_count': len(record.get('InvoiceCorrections', []) or []),
'has_damage': item_has_damage,
'damage_count': len(damage_reports),
'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''),
@@ -1306,6 +1498,19 @@ def api_library_scan_action():
it.update_item_status(item_id, False, borrower_name)
au.add_ausleihung(item_id, borrower_name, now, end_date=end_date)
_append_audit_event(
db,
'ausleihung_borrowed',
{
'channel': 'library_scan',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'borrower': borrower_name,
'student_card_id': student_card_id,
'borrow_duration_days': borrow_duration_days,
}
)
return jsonify({
'ok': True,
'action': 'borrowed',
@@ -1335,6 +1540,19 @@ def api_library_scan_action():
)
it.update_item_status(item_id, True, borrower_name)
_append_audit_event(
db,
'ausleihung_returned',
{
'channel': 'library_scan',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'borrower': borrower_name,
'student_card_id': student_card_id,
'completed_records': update_result.modified_count,
}
)
return jsonify({
'ok': True,
'action': 'returned',
@@ -2294,14 +2512,16 @@ def get_items():
items_col = db['items']
items_cur = items_col.find({
'IsGroupedSubItem': {'$ne': True},
'ItemType': {'$nin': LIBRARY_ITEM_TYPES}
'ItemType': {'$nin': LIBRARY_ITEM_TYPES},
'Deleted': {'$ne': True},
})
items = []
for itm in items_cur:
item_id_str = str(itm['_id'])
grouped_children = list(items_col.find({
'ParentItemId': item_id_str,
'IsGroupedSubItem': True
'IsGroupedSubItem': True,
'Deleted': {'$ne': True},
}))
grouped_count = 1 + len(grouped_children)
@@ -2341,7 +2561,7 @@ def get_item_json(id):
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
item = db['items'].find_one({'_id': ObjectId(id)})
item = db['items'].find_one({'_id': ObjectId(id), 'Deleted': {'$ne': True}})
if not item:
return jsonify({'error': 'not found'}), 404
item['_id'] = str(item['_id'])
@@ -2593,6 +2813,10 @@ def upload_item():
flash('Fehler beim Verarbeiten der Formulardaten. Bitte versuchen Sie es erneut.', 'error')
return redirect(url_for('home_admin'))
# Expand special "all values" selections for predefined filters.
filter_upload = expand_filter_selection(filter_upload, 1)
filter_upload2 = expand_filter_selection(filter_upload2, 2)
# Validation
if not name or not ort or not beschreibung:
error_msg = 'Bitte füllen Sie alle erforderlichen Felder aus'
@@ -3570,43 +3794,68 @@ def delete_item(id):
flash('Element nicht gefunden.', 'error')
return redirect(url_for('home_admin'))
# Collect all referenced images once to avoid duplicate deletion attempts
image_filenames = []
seen_images = set()
for group_item in group_items:
for filename in group_item.get('Images', []):
if filename and filename not in seen_images:
seen_images.add(filename)
image_filenames.append(filename)
# Attempt to delete image files
stats = {'originals': 0, 'thumbnails': 0, 'previews': 0, 'errors': 0}
try:
stats = delete_item_images(image_filenames)
app.logger.info(f"Item group deletion ({len(group_item_ids)} IDs) - Images removed: " +
f"originals={stats['originals']}, thumbnails={stats['thumbnails']}, " +
f"previews={stats['previews']}, errors={stats['errors']}")
except Exception as e:
app.logger.error(f"Error deleting images for item group {id}: {str(e)}")
# Delete all items in the group and related borrow entries
# GoBD hardening: keep files and records immutable, use soft-delete markers only.
delete_success = True
for group_item_id in group_item_ids:
if not it.remove_item(group_item_id):
delete_success = False
soft_deleted_items = 0
soft_deleted_borrows = 0
now = datetime.datetime.now()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['ausleihungen'].delete_many({'Item': {'$in': group_item_ids}})
client.close()
for group_item_id in group_item_ids:
result = db['items'].update_one(
{'_id': ObjectId(group_item_id), 'Deleted': {'$ne': True}},
{'$set': {
'Deleted': True,
'DeletedAt': now,
'DeletedBy': session.get('username', ''),
'LastUpdated': now,
'Verfuegbar': False,
}}
)
if result.modified_count > 0:
soft_deleted_items += 1
elif result.matched_count == 0:
delete_success = False
borrow_result = db['ausleihungen'].update_many(
{
'Item': {'$in': group_item_ids},
'Status': {'$ne': 'deleted'}
},
{'$set': {
'Status': 'deleted',
'DeletedAt': now,
'DeletedBy': session.get('username', ''),
'LastUpdated': now,
}}
)
soft_deleted_borrows = borrow_result.modified_count
_append_audit_event(
db,
'inventory_item_soft_deleted',
{
'root_item_id': id,
'group_item_ids': group_item_ids,
'soft_deleted_items': soft_deleted_items,
'soft_deleted_borrow_records': soft_deleted_borrows,
}
)
except Exception as e:
app.logger.error(f"Error deleting borrowing records for item group {id}: {str(e)}")
app.logger.error(f"Error during soft-delete for item group {id}: {str(e)}")
delete_success = False
finally:
if client:
client.close()
if delete_success:
flash(f'Elementgruppe erfolgreich gelöscht ({len(group_item_ids)} Versionen). {stats["originals"]} Bilder entfernt.', 'success')
flash(f'Elementgruppe revisionssicher deaktiviert ({soft_deleted_items}/{len(group_item_ids)} Versionen).', 'success')
else:
flash('Fehler beim Löschen des Elements aus der Datenbank.', 'error')
flash('Fehler beim revisionssicheren Deaktivieren des Elements.', 'error')
return redirect(url_for('home_admin'))
@@ -3638,6 +3887,10 @@ def edit_item(id):
filter1 = sanitize_form_value(request.form.getlist('filter'))
filter2 = sanitize_form_value(request.form.getlist('filter2'))
filter3 = sanitize_form_value(request.form.getlist('filter3'))
# Expand special "all values" selections for predefined filters.
filter1 = expand_filter_selection(filter1, 1)
filter2 = expand_filter_selection(filter2, 2)
anschaffungs_jahr = sanitize_form_value(request.form.get('anschaffungsjahr'))
anschaffungs_kosten = sanitize_form_value(request.form.get('anschaffungskosten'))
@@ -4060,6 +4313,18 @@ def ausleihen(id):
it.update_item_status(unit_id, False, effective_borrower)
au.add_ausleihung(unit_id, effective_borrower, start_date, end_date=end_date)
_append_audit_event_standalone(
'ausleihung_borrowed',
{
'channel': 'inventory_route',
'item_id': id,
'borrower': effective_borrower,
'borrow_duration_days': borrow_duration_days,
'selected_unit_ids': [str(unit.get('_id')) for unit in selected_units],
'selected_unit_codes': [str(unit.get('Code_4') or '') for unit in selected_units],
}
)
if len(selected_units) == 1:
selected_code = selected_units[0].get('Code_4') or '-'
flash(f'Exemplar {selected_code} erfolgreich ausgeliehen', 'success')
@@ -4138,6 +4403,16 @@ def ausleihen(id):
it.update_item_status(id, False, effective_borrower)
start_date = datetime.datetime.now()
au.add_ausleihung(id, effective_borrower, start_date, end_date=end_date)
_append_audit_event_standalone(
'ausleihung_borrowed',
{
'channel': 'inventory_route',
'item_id': id,
'borrower': effective_borrower,
'borrow_duration_days': borrow_duration_days,
'single_item': True,
}
)
flash('Element erfolgreich ausgeliehen', 'success')
else:
# Handle multi-exemplar item
@@ -4176,6 +4451,17 @@ def ausleihen(id):
'parent_id': id,
'exemplar_number': exemplar['number']
})
_append_audit_event_standalone(
'ausleihung_borrowed',
{
'channel': 'inventory_route',
'item_id': id,
'borrower': effective_borrower,
'borrow_duration_days': borrow_duration_days,
'exemplar_numbers': [ex.get('number') for ex in new_borrowed_exemplars],
}
)
flash(f'{exemplare_count} Exemplare erfolgreich ausgeliehen', 'success')
@@ -4251,6 +4537,17 @@ def zurueckgeben(id):
flash(f'Element erfolgreich zurückgegeben ({updated_count} Datensätze abgeschlossen)', 'success')
else:
flash('Element erfolgreich zurückgegeben', 'success')
_append_audit_event_standalone(
'ausleihung_returned',
{
'channel': 'inventory_route',
'item_id': id,
'returned_by': username,
'original_borrower': original_user,
'completed_records': updated_count,
}
)
except Exception as e:
print(f"Error in return process: {e}")
@@ -4986,6 +5283,8 @@ def admin_borrowings():
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
_ensure_audit_indexes_once()
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen = db['ausleihungen']
@@ -5038,6 +5337,7 @@ def admin_borrowings():
'invoice_created_at': fmt_dt(invoice_data.get('created_at')) if isinstance(invoice_data.get('created_at'), datetime.datetime) else '',
'invoice_paid': bool(invoice_data.get('paid', False)),
'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '',
'invoice_corrections_count': len(r.get('InvoiceCorrections', []) or []),
'has_damage': has_damage,
})
@@ -5051,6 +5351,152 @@ def admin_borrowings():
)
@app.route('/admin/audit/verify', methods=['GET'])
def admin_verify_audit_chain():
"""Admin endpoint to verify audit chain integrity."""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
status_code = 200 if result.get('ok') else 409
return jsonify(result), status_code
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
finally:
if client:
client.close()
@app.route('/admin/audit', methods=['GET'])
def admin_audit_dashboard():
"""Admin dashboard for audit chain status and recent events."""
if 'username' not in session or not us.check_admin(session['username']):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(200)
)
return render_template(
'admin_audit.html',
verify_result=verify_result,
audit_rows=audit_rows,
library_module_enabled=cfg.LIBRARY_MODULE_ENABLED,
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED,
)
except Exception as exc:
app.logger.error(f"Error loading audit dashboard: {exc}")
flash('Fehler beim Laden des Audit-Dashboards.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
client.close()
@app.route('/admin/audit/export', methods=['GET'])
def admin_audit_export():
"""Export a detailed audit review in markdown or json format."""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
fmt = (request.args.get('format') or 'md').strip().lower()
try:
limit = int((request.args.get('limit') or '1000').strip())
except Exception:
limit = 1000
limit = max(1, min(limit, 5000))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
event_counts = list(
db['audit_log'].aggregate([
{'$group': {'_id': '$event_type', 'count': {'$sum': 1}}},
{'$project': {'_id': 0, 'event_type': {'$ifNull': ['$_id', 'unknown']}, 'count': 1}},
{'$sort': {'count': -1, 'event_type': 1}}
])
)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(limit)
)
generated_at = datetime.datetime.utcnow().isoformat() + 'Z'
if fmt == 'json':
export_payload = {
'generated_at': generated_at,
'verify_result': verify_result,
'event_counts': event_counts,
'events': audit_rows,
}
response = make_response(json.dumps(export_payload, ensure_ascii=False, indent=2, default=str))
response.headers['Content-Type'] = 'application/json; charset=utf-8'
response.headers['Content-Disposition'] = f'attachment; filename=audit-review-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.json'
return response
markdown_content = _build_audit_review_markdown(
verify_result=verify_result,
event_counts=event_counts,
audit_rows=audit_rows,
generated_at=generated_at,
)
response = make_response(markdown_content)
response.headers['Content-Type'] = 'text/markdown; charset=utf-8'
response.headers['Content-Disposition'] = f'attachment; filename=audit-review-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.md'
return response
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
finally:
if client:
client.close()
@app.route('/admin/reset_borrowing/<borrow_id>', methods=['POST'])
def admin_reset_borrowing(borrow_id):
"""
@@ -5088,9 +5534,31 @@ def admin_reset_borrowing(borrow_id):
except Exception:
pass
flash('Aktive Ausleihe wurde zurückgesetzt (abgeschlossen).', 'success')
_append_audit_event(
db,
'ausleihung_admin_reset',
{
'borrow_id': borrow_id,
'from_status': 'active',
'to_status': 'completed',
'item_id': str(item_id or ''),
'borrower': str(user or ''),
}
)
elif status == 'planned':
ausleihungen.update_one({'_id': rec['_id']}, {'$set': {'Status': 'cancelled', 'LastUpdated': now}})
flash('Geplante Ausleihe wurde storniert.', 'success')
_append_audit_event(
db,
'ausleihung_admin_reset',
{
'borrow_id': borrow_id,
'from_status': 'planned',
'to_status': 'cancelled',
'item_id': str(item_id or ''),
'borrower': str(user or ''),
}
)
else:
flash('Diese Ausleihe ist weder aktiv noch geplant.', 'warning')
@@ -5152,6 +5620,10 @@ def admin_create_invoice(borrow_id):
now = datetime.datetime.now()
existing_invoice = borrow_doc.get('InvoiceData') or {}
if existing_invoice.get('invoice_number'):
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
return redirect(url_for('admin_borrowings'))
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
borrower = borrow_doc.get('User', '')
item_name = item_doc.get('Name', '')
@@ -5177,6 +5649,7 @@ def admin_create_invoice(borrow_id):
update_fields = {
'InvoiceData': invoice_data,
'InvoiceLocked': True,
'LastUpdated': now,
}
if close_borrowing:
@@ -5224,6 +5697,19 @@ def admin_create_invoice(borrow_id):
except Exception as log_err:
app.logger.warning(f"Damage invoice log write failed for borrow {borrow_id}: {log_err}")
_append_audit_event(
db,
'invoice_created',
{
'borrow_id': borrow_id,
'invoice_number': invoice_number,
'amount': round(amount_value, 2),
'mark_destroyed': mark_destroyed,
'close_borrowing': close_borrowing,
'item_id': str(item_doc.get('_id')),
}
)
pdf_buffer = _build_invoice_pdf(invoice_data)
return send_file(
pdf_buffer,
@@ -5298,6 +5784,16 @@ def admin_mark_invoice_paid(borrow_id):
except Exception as log_err:
app.logger.warning(f"Damage invoice paid log write failed for borrow {borrow_id}: {log_err}")
_append_audit_event(
db,
'invoice_marked_paid',
{
'borrow_id': borrow_id,
'invoice_number': invoice_data.get('invoice_number', ''),
'amount': invoice_data.get('amount'),
}
)
flash('Rechnung wurde als bezahlt markiert.', 'success')
return redirect(url_for('admin_borrowings'))
except Exception as e:
@@ -5403,6 +5899,19 @@ def admin_finalize_invoice_and_repair(borrow_id):
except Exception as log_err:
app.logger.warning(f"Damage invoice finalize log write failed for borrow {borrow_id}: {log_err}")
_append_audit_event(
db,
'invoice_finalized_and_repaired',
{
'borrow_id': borrow_id,
'item_id': str(item_doc.get('_id')) if item_doc else '',
'invoice_number': invoice_data.get('invoice_number', ''),
'amount': invoice_data.get('amount'),
'repaired': repaired,
'resolved_damage_reports': resolved_count,
}
)
if repaired:
flash('Rechnung als bezahlt markiert und Element als repariert abgeschlossen.', 'success')
else:
@@ -5467,6 +5976,83 @@ def admin_view_invoice_pdf(borrow_id):
client.close()
@app.route('/admin/borrowings/<borrow_id>/invoice/correction', methods=['POST'])
def admin_add_invoice_correction(borrow_id):
"""Append an invoice correction entry without mutating the original invoice body."""
if 'username' not in session or not us.check_admin(session['username']):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen = db['ausleihungen']
borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}, {'InvoiceData': 1})
if not borrow_doc:
flash('Ausleihung nicht gefunden.', 'error')
return redirect(url_for('admin_borrowings'))
invoice_data = borrow_doc.get('InvoiceData') or {}
if not invoice_data:
flash('Korrektur nicht möglich: Es existiert noch keine Rechnung.', 'warning')
return redirect(url_for('admin_borrowings'))
correction_reason = str(request.form.get('correction_reason', '')).strip()
if not correction_reason:
flash('Bitte eine Begründung für die Korrektur angeben.', 'error')
return redirect(url_for('admin_borrowings'))
delta_raw = request.form.get('amount_delta')
delta_value = _parse_money_value(delta_raw) if delta_raw not in (None, '') else 0.0
if delta_value is None:
flash('Ungültiger Korrekturbetrag.', 'error')
return redirect(url_for('admin_borrowings'))
now = datetime.datetime.now()
correction_number = f"CORR-{now.strftime('%Y%m%d-%H%M%S')}-{str(borrow_doc.get('_id'))[-6:].upper()}"
correction_entry = {
'correction_number': correction_number,
'reason': correction_reason,
'amount_delta': round(delta_value, 2),
'amount_delta_text': _format_money_value(delta_value),
'created_at': now,
'created_by': session.get('username', ''),
'invoice_number': invoice_data.get('invoice_number', ''),
}
ausleihungen.update_one(
{'_id': borrow_doc['_id']},
{
'$push': {'InvoiceCorrections': {'$each': [correction_entry], '$position': 0}},
'$set': {'LastUpdated': now, 'InvoiceLocked': True}
}
)
_append_audit_event(
db,
'invoice_correction_added',
{
'borrow_id': borrow_id,
'invoice_number': invoice_data.get('invoice_number', ''),
'correction_number': correction_number,
'amount_delta': round(delta_value, 2),
'reason': correction_reason,
}
)
flash('Korrekturbuchung wurde revisionssicher ergänzt.', 'success')
return redirect(url_for('admin_borrowings'))
except Exception as e:
app.logger.error(f"Error creating invoice correction for borrow {borrow_id}: {e}")
flash('Fehler beim Anlegen der Korrekturbuchung.', 'error')
return redirect(url_for('admin_borrowings'))
finally:
if client:
client.close()
@app.route('/admin/library/items/<item_id>/invoices', methods=['GET'])
def library_item_invoices(item_id):
"""Show all stored invoices for one specific library item."""
@@ -5861,6 +6447,10 @@ def add_filter_value(filter_num):
if not value:
flash('Bitte geben Sie einen Wert ein', 'error')
return redirect(url_for('manage_filters'))
if value == FILTER_SELECT_ALL_TOKEN:
flash('Dieser Wert ist reserviert und kann nicht als Filterwert gespeichert werden.', 'error')
return redirect(url_for('manage_filters'))
# Add the value to the filter
success = it.add_predefined_filter_value(filter_num, value)
@@ -6690,6 +7280,16 @@ def cancel_ausleihung_route(id):
if au.cancel_ausleihung(id):
print(f"Successfully canceled ausleihung with ID: {id}")
flash('Ausleihung wurde erfolgreich storniert', 'success')
_append_audit_event_standalone(
'ausleihung_cancelled',
{
'borrow_id': id,
'item_id': str(ausleihung.get('Item') or ''),
'cancelled_by': username,
'owner_user': str(ausleihung_user or ''),
'status_before': str(ausleihung_status or ''),
}
)
# Also clear NextAppointment on the related item if it matches this appointment
try:
item_id = str(ausleihung.get('Item')) if ausleihung.get('Item') is not None else None
+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,
}
+19 -11
View File
@@ -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
+45 -22
View File
@@ -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'])
+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>
+10 -66
View File
@@ -7,7 +7,7 @@
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!DOCTYPE html>
<html lang="en" data-module="inventory">
<html lang="en" 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>
@@ -369,7 +369,7 @@
<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 +377,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 +386,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>
@@ -459,6 +418,7 @@
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
@@ -490,9 +450,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">
@@ -555,23 +516,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 %}
@@ -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>
+57
View File
@@ -1923,6 +1923,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 +1968,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 = {};
+102 -12
View File
@@ -2847,6 +2847,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 => {
@@ -3528,7 +3533,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 +3552,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 +4434,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 +4477,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 = {};
+8
View File
@@ -1114,9 +1114,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;
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""CLI utility to verify the tamper-evident audit chain."""
import json
import sys
from pymongo import MongoClient
import settings as cfg
import audit_log as al
def main():
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0 if result.get("ok") else 2
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -1,7 +1,7 @@
{
"dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "3.2.1",
"ver": "0.0.2",
"host": "0.0.0.0",
"port": 443,
+1
View File
@@ -42,6 +42,7 @@ services:
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
+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
+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
+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"