Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 444a1df1aa | |||
| 0581b5c5e9 | |||
| 2faf284a0e | |||
| 025e03f9ce | |||
| a96e103733 | |||
| b636d06908 | |||
| a85461711c | |||
| bf0a50ad57 | |||
| 9212ad04f5 | |||
| 1931e07013 | |||
| c81aa882e9 | |||
| 142e6e990e | |||
| 9333b5cdd0 | |||
| b11739de3b | |||
| 269f084227 | |||
| 46b9e0b3b2 | |||
| 4554e5ee02 | |||
| ecea6740f5 | |||
| a8122f9afd | |||
| 71026b8eab |
@@ -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
@@ -1,4 +1,5 @@
|
||||
dist
|
||||
logs
|
||||
certs
|
||||
build
|
||||
build
|
||||
.venv
|
||||
@@ -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.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Inventarsystem
|
||||
|
||||
[](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml)
|
||||
|
||||
[](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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'])
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container" style="max-width:1400px; margin:0 auto; padding:18px;">
|
||||
<h1>Audit Dashboard</h1>
|
||||
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
|
||||
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 18px;">
|
||||
<a class="btn btn-primary" href="{{ url_for('admin_audit_export', format='md') }}">Audit Review exportieren (Markdown)</a>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}">Audit Review exportieren (JSON)</a>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
|
||||
{% if verify_result.ok %}
|
||||
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
|
||||
{% else %}
|
||||
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Einträge</div>
|
||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
|
||||
</div>
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
|
||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
|
||||
</div>
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Mismatch Count</div>
|
||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if verify_result.mismatches %}
|
||||
<div style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;">
|
||||
<h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3>
|
||||
<div style="max-height:280px; overflow:auto;">
|
||||
<table class="table" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Typ</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Expected</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Found</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in verify_result.mismatches %}
|
||||
<tr>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.chain_index }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.error }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.expected }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.found }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:12px;">
|
||||
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
|
||||
<div style="max-height:560px; overflow:auto;">
|
||||
<table class="table" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Zeit</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Event</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Actor</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Hash</th>
|
||||
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in audit_rows %}
|
||||
<tr>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.chain_index }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.timestamp or row.created_at }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.event_type }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.actor }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace; font-size:0.8rem;">{{ row.entry_hash }}</td>
|
||||
<td style="padding:8px; border-bottom:1px solid #eee;"><pre style="margin:0; white-space:pre-wrap; font-size:0.8rem;">{{ row.payload }}</pre></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -100,6 +100,9 @@
|
||||
{% if e.invoice_number %}
|
||||
<div style="font-weight:600;">{{ e.invoice_number }}</div>
|
||||
<div style="font-size:0.85rem; color:#666;">{{ e.invoice_amount }}</div>
|
||||
{% if e.invoice_corrections_count %}
|
||||
<div style="font-size:0.78rem; color:#7c2d12; margin-top:4px;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
|
||||
{% endif %}
|
||||
{% if e.invoice_paid %}
|
||||
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Bezahlt</div>
|
||||
{% if e.invoice_paid_at %}
|
||||
@@ -129,6 +132,13 @@
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if e.invoice_number %}
|
||||
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');">
|
||||
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px;">
|
||||
<input type="text" name="amount_delta" placeholder="Delta (optional)" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
|
||||
<button type="submit" class="btn btn-outline-danger">Korrektur</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
|
||||
<button type="submit" class="btn btn-warning">Zurücksetzen</button>
|
||||
</form>
|
||||
|
||||
+10
-66
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"dbg": false,
|
||||
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
|
||||
"ver": "3.2.1",
|
||||
"ver": "0.0.2",
|
||||
"host": "0.0.0.0",
|
||||
"port": 443,
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user