Compare commits

...

88 Commits

Author SHA1 Message Date
Aiirondev_dev e7f8b8d76a fix(tenant-manager): add docker-cli-compose package to alpine container 2026-05-08 15:27:25 +02:00
Aiirondev_dev d6ac8a9e16 fix(ui): include /home_admin and other admin routes in inventory module detection 2026-05-08 15:18:34 +02:00
Aiirondev_dev c7cfb0e411 fix(ui): ensure /home route correctly activates the inventory theme and navbar 2026-05-08 14:54:55 +02:00
Aiirondev_dev 6c73f79866 Enhance module navigation: update inventory and library navbar logic for improved module access and user session handling 2026-05-08 12:03:38 +02:00
Aiirondev_dev 5cb59f4b63 Enhance module management: add inventory module support in settings, update templates and app logic for module access control, and extend tenant management script for module configuration 2026-05-08 11:24:53 +02:00
Aiirondev_dev 326fc4ef91 Enhance Docker configuration: update .docker-build.env and .docker-compose.runtime.override.yml for improved resource allocation and service settings 2026-05-01 10:51:43 +02:00
Aiirondev_dev 12cd365f36 Enhance filter retrieval: update get_filter API to return full filter lists and modify frontend to utilize them for improved item filtering 2026-04-29 17:17:54 +02:00
Aiirondev_dev b06828cc7e Enhance logging: change log level to DEBUG and add detailed database connection logging in user and tenant modules
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 13:44:22 +02:00
Aiirondev_dev 79f07d8631 Enhance logging: update logger configuration to use 'app' and set log level to INFO
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 13:30:52 +02:00
Aiirondev_dev 2ef5056727 Enhance logging: add detailed context and error handling in tenant resolution and user authentication
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 12:57:21 +02:00
Aiirondev_dev 46745dba49 Enhance user authentication: add logging for missing tenant databases during login attempts
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 12:47:18 +02:00
Aiirondev_dev c71da2fabf Enhance MongoDB configuration: add support for MongoDB URI and update connection logic 2026-04-29 11:50:14 +02:00
Aiirondev_dev e822ea36bf Enhance logging: implement rotating file handler and log tenant resolution details in context manager 2026-04-29 11:32:42 +02:00
Aiirondev_dev cd767caac7 Enhance login page: display active tenant and database information in an alert 2026-04-29 11:24:10 +02:00
Aiirondev_dev 8b9766234b Enhance tenant context handling: add tenant database information to version injection and update navbar to display active tenant 2026-04-29 11:16:39 +02:00
Aiirondev_dev 0b5b340463 Remove init-admin.sh references from documentation and update permissions in scripts
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 10:36:49 +02:00
Aiirondev_dev f80430f243 Remove legacy scripts and single-tenant support
- Deleted the migration script `migrate-to-multitenant.sh` as it is no longer needed.
- Removed `rebuild-venv.sh`, `restore.sh`, `run-backup.sh`, and other related scripts that are not compatible with the new multi-tenant architecture.
- Updated `restart.sh`, `start.sh`, `stop.sh`, `update.sh`, and other scripts to eliminate references to single-tenant deployment, ensuring they only support multi-tenant configurations.

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 10:29:46 +02:00
Aiirondev_dev c8f9c87ba4 Refactor MongoClient import: standardize import statements across push_notifications, tenant, and verify_audit_chain modules 2026-04-29 00:05:55 +02:00
Aiirondev_dev c2bb015b80 Enhance tenant database access: implement context-aware database retrieval for user operations 2026-04-28 23:48:41 +02:00
Aiirondev_dev 35c4e9c6f6 Enhance admin management: improve username case handling and return status for admin updates 2026-04-28 23:35:58 +02:00
Aiirondev_dev 3c5ee65b03 Enhance username generation: implement unique username creation from the first two letters of the first and last name 2026-04-28 23:27:28 +02:00
Aiirondev_dev 0edb87c347 Enhance user registration: implement generation of truly anonymized usernames for new users 2026-04-28 23:20:59 +02:00
Aiirondev_dev 678cc61cef Enhance Docker Compose and startup script: implement auto-scaling settings based on server capacity and environment variables for worker configuration 2026-04-28 23:05:55 +02:00
Aiirondev_dev f26ab5c5fb Enhance user retrieval: implement tenant-aware user search with fallback to default and tenant-specific databases 2026-04-28 22:59:14 +02:00
Aiirondev_dev abb09fbdb5 Enhance user authentication: improve tenant database handling and implement fallback user search across tenant databases 2026-04-28 22:26:02 +02:00
Aiirondev_dev 96f5b9e3cd Enhance tutorial functionality: add tooltip toggle feature and improve tooltip display across the system 2026-04-28 21:59:26 +02:00
Aiirondev_dev 3ab2b075da Enhance tenant database management: integrate TenantContext for dynamic database naming and improve database drop functionality 2026-04-28 21:29:06 +02:00
Aiirondev_dev 821d4d0ad8 Enhance user authentication: improve password validation and tenant context handling 2026-04-28 21:21:48 +02:00
Aiirondev_dev 6cc8dabf72 Enhance runtime override generation: add app image retrieval and improve port handling 2026-04-28 20:55:58 +02:00
Aiirondev_dev 0d7fa2c511 Enhance tenant management script: improve port parsing and runtime override generation 2026-04-28 20:38:19 +02:00
Aiirondev_dev 4a18460178 Enhance tenant management script: add functionality to remove tenant ports and update runtime port configuration 2026-04-28 19:00:08 +02:00
Aiirondev_dev d2b3d4f6ea Enhance tenant management script: improve tenant listing functionality and handle missing config file 2026-04-28 18:44:47 +02:00
Aiirondev_dev f3d327f28f Enhance tenant management scripts: add runtime port update functionality and handle occupied ports 2026-04-28 18:18:04 +02:00
Aiirondev_dev 2b05d8c952 Refactor manage-tenant script: improve parameter handling and ensure default values 2026-04-28 17:54:43 +02:00
Aiirondev_dev 6cb41c1277 Enhance tenant management script: add help option to display usage instructions 2026-04-28 17:49:44 +02:00
Aiirondev_dev f0d02d6af1 Refactor restart script: streamline Docker compose argument handling and improve readability 2026-04-28 17:20:10 +02:00
Aiirondev_dev 6f5e24104b Refactor restart script: improve Docker container restart logic and update completion messages 2026-04-28 17:06:37 +02:00
Aiirondev_dev 43406c29d1 Enhance tenant management script: switch to bash, enforce strict mode, and validate port input 2026-04-28 16:54:42 +02:00
Aiirondev_dev 7873c45cfc Refactor multi-tenant deployment: update port handling in scripts, add tenant port registration, and enhance Docker configurations
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 16:36:55 +02:00
Aiirondev_dev 14c4192306 Refactor Docker setup: remove Nginx and TLS configurations, update service dependencies, and adjust health check endpoints
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 15:41:10 +02:00
Aiirondev_dev 1efc7e01be Update HTTP and HTTPS port configuration in .docker-build.env
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:38:44 +02:00
Aiirondev_dev d567ba583b Update port configuration for multi-tenant deployment to avoid conflicts
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:21:34 +02:00
Aiirondev_dev 5a5af5375d Add missing Nginx configuration for SSL and proxy settings
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 11:17:33 +02:00
Aiirondev_dev a60eb6eebf Update health check endpoint in verify_stack_health function in start.sh and update.sh
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:55:29 +02:00
Aiirondev_dev 23b7a4cd2f Remove obsolete configuration file config.yml 2026-04-26 21:38:02 +02:00
Aiirondev_dev e6fd485049 Update service configuration in config.yml and simplify cloudflared command in docker-compose 2026-04-26 17:01:20 +02:00
Aiirondev_dev 15d9eba987 Fix server block syntax in Nginx configuration in update.sh 2026-04-26 16:34:42 +02:00
Aiirondev_dev 05d6d299da Enhance scheduler lock file handling and add health check endpoint 2026-04-26 16:17:29 +02:00
Aiirondev_dev ab9db1211c Add server cleanup function to update.sh for Docker and log management
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:51:14 +02:00
Aiirondev_dev 45b69e5ddb Update HTTPS port configuration and add cloudflared setup in Docker Compose 2026-04-26 15:35:29 +02:00
Aiirondev_dev 4971bc859b Add disk space checks and cleanup functions in start.sh and update.sh 2026-04-24 21:29:39 +02:00
Aiirondev_dev b7f55b0de0 Remove custom command for MongoDB service in Docker Compose 2026-04-24 21:18:53 +02:00
Aiirondev_dev 9c24e79bba Add retry mechanism for Docker Compose startup in start.sh 2026-04-24 21:13:31 +02:00
Aiirondev_dev 79c325329c Refactor cloudflared configuration to use external config file and update command syntax 2026-04-24 21:05:21 +02:00
Aiirondev_dev 8f81ffb4c5 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-24 20:58:09 +02:00
Aiirondev_dev 1d7692ea01 Add cloudflared service and configure tunnel profile in Docker Compose 2026-04-24 20:56:15 +02:00
Aiirondev_dev 038390b8cd Add multi-tenant configuration support with dynamic module enabling 2026-04-24 09:16:04 +02:00
Aiirondev_dev f2c1dc2ba5 Implement session validation for active users before request processing
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:00:22 +02:00
Aiirondev_dev 3eeae76e6c Remove Web directory volume mount from Docker Compose files for app service 2026-04-23 22:48:23 +02:00
Aiirondev_dev 0228e6cb1d Add working directory and command configuration for app service in Docker Compose files 2026-04-23 22:41:52 +02:00
Aiirondev_dev 4c59cca1a4 Add missing nginx configuration for SSL support and error handling 2026-04-23 22:36:01 +02:00
Aiirondev_dev 8412ae76ee Fix volume mount for Web directory in Docker Compose files: remove read-only flag for better accessibility 2026-04-23 22:32:01 +02:00
Aiirondev_dev ec4483c415 Enhance backup and restore scripts: update database name and add support for multi-tenant configurations 2026-04-23 22:26:54 +02:00
Aiirondev_dev 52656c715e Enhance mobile library loading: implement virtualization for improved performance and memory management on mobile devices
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:05:52 +02:00
Aiirondev_dev f6755aad42 Enhance mobile library loading: implement dynamic item rendering and lazy loading for improved performance on mobile devices 2026-04-23 21:50:49 +02:00
Aiirondev_dev cbbcc09fc2 Refactor navbar and search form styles for improved layout: adjust flex properties and widths for better responsiveness 2026-04-22 19:37:39 +02:00
Aiirondev_dev aa8912e8b7 Enhance navbar functionality: add data attribute for fixed navigation items and improve overflow detection logic 2026-04-22 17:34:19 +02:00
Aiirondev_dev 68488598af Enhance navigation links for borrowed items and tutorials: add conditional rendering for 'Meine Ausleihen' and 'Tutorial' links based on user permissions 2026-04-22 17:26:44 +02:00
Aiirondev_dev 6e8bb8236e Enhance navbar overflow functionality: refactor initNavbarOverflow to accept options for more toggle ID and improve handling of hidden navigation items 2026-04-22 16:50:46 +02:00
Aiirondev_dev bd92d60d08 Refine navbar styles for improved responsiveness: adjust padding, gap, and font sizes for various screen sizes 2026-04-22 16:39:29 +02:00
Aiirondev_dev 4991587d99 Enhance safe area support for iOS devices: update CSS for navbar and body to respect safe area insets and improve layout on mobile screens 2026-04-22 16:25:45 +02:00
Aiirondev_dev 2b8bf0b7d5 Update Docker configuration and scripts for multi-tenant support: change app image, adjust port settings, and clean up logs 2026-04-21 23:20:42 +02:00
Aiirondev_dev 9ae316cdd9 Enhance multi-tenant support: update scripts to handle multi-tenant Docker compose files and secure admin credentials 2026-04-21 16:57:43 +02:00
Aiirondev_dev 12f62e5146 Change Docker image for tenant-manager service and update entrypoint to use sh; modify shebang in manage-tenant.sh to use sh 2026-04-21 16:26:48 +02:00
Aiirondev_dev 9ddaaaae1e Add tenant-manager service to docker-compose for multi-tenant management 2026-04-21 16:19:19 +02:00
Aiirondev_dev 3a1518571b Fix script to use 'docker compose' command for tenant management 2026-04-20 22:14:25 +02:00
Aiirondev_dev ebb266e099 Add multi-tenant resource copying and ensure executable permissions for new scripts 2026-04-20 21:59:04 +02:00
Aiirondev_dev df677908d0 Add multitenant scripts and documentation to release bundle; remove outdated build script 2026-04-20 21:39:24 +02:00
Aiirondev_dev bc61e87ab0 Refactor UI styles to use CSS variables for consistent theming
- Updated background colors across various templates to use CSS variables for better theming support.
- Replaced hardcoded colors with variables like `var(--ui-surface)` and `var(--ui-surface-soft)` for backgrounds.
- Adjusted text colors to use `var(--ui-text)` for improved readability and consistency.
- Enhanced the theme toggle functionality in the base template to dynamically change theme colors based on user preference.
- Ensured that all relevant templates reflect the new theming approach for a cohesive user experience.
2026-04-20 20:20:33 +02:00
Aiirondev_dev 2d37ded5be Remove outdated German section and streamline tenant management documentation for clarity 2026-04-20 20:05:44 +02:00
Aiirondev_dev 31de22d460 Add detailed explanation of multi-tenant architecture and operational guidelines in the documentation 2026-04-20 15:55:50 +02:00
Aiirondev_dev 96f918828c Refactor tenant management script to use MongoClient directly for database operations and add documentation for multi-tenant architecture 2026-04-20 15:48:46 +02:00
Aiirondev_dev eb912c3a73 Add tenant management container and update management script for improved tenant operations 2026-04-20 15:36:02 +02:00
Aiirondev_dev 3044b36e9f Add tenant management script for zero-downtime operations and database handling 2026-04-20 15:31:46 +02:00
Aiirondev_dev e464628ab7 Enhance admin content layout with responsive design adjustments and improved styling for item cards 2026-04-20 14:34:17 +02:00
Aiirondev_dev a5a6f89f18 Add predefined filters and update cost labels to include currency symbol 2026-04-20 14:10:23 +02:00
Aiirondev_dev 6835ee87a7 Update modal button class and increase z-index for improved visibility 2026-04-20 09:40:08 +02:00
Aiirondev_dev 3a9d45327a Improve mobile layout with viewport adjustments, scrolling enhancements, and refined modal styling 2026-04-20 09:31:11 +02:00
71 changed files with 3666 additions and 5169 deletions
+11 -3
View File
@@ -1,4 +1,12 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTP_PORTS=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
INVENTAR_APP_CPUS=1.0
INVENTAR_APP_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m
INVENTAR_WORKERS=4
INVENTAR_THREADS=2
INVENTAR_WORKER_TIMEOUT=30
INVENTAR_WORKER_CONNECTIONS=100
+8
View File
@@ -0,0 +1,8 @@
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
build: null
ports:
- "10000:8000"
+41 -33
View File
@@ -158,42 +158,17 @@ jobs:
- name: Create release-only docker bundle
run: |
mkdir -p release-bundle
mkdir -p release-bundle/docker/nginx
cat > release-bundle/docker-compose.yml <<EOF
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
app:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest}
pull_policy: never
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
container_name: inventarsystem-app
restart: unless-stopped
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
depends_on:
mongodb:
condition: service_healthy
- mongodb
- redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
@@ -211,6 +186,33 @@ jobs:
- app_backups:/data/backups
- app_logs:/data/logs
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongodb_data:
app_uploads:
@@ -219,18 +221,24 @@ jobs:
app_qrcodes:
app_backups:
app_logs:
redis_data:
EOF
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
cp backup.sh release-bundle/backup.sh
cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh
cp init-admin.sh release-bundle/init-admin.sh
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
# Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
cp manage-tenant.sh release-bundle/manage-tenant.sh
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release
-177
View File
@@ -1,177 +0,0 @@
# Fehlersuche: Geplante Ausleihen werden nicht automatisch aktiviert
## 🔍 Mögliche Ursachen
### 1. **Termin wurde für falsches Datum erstellt**
Die meisten Probleme entstehen, weil der Termin für ein **falsches Datum** (z.B. morgen statt heute) erstellt wurde.
### 2. **Scheduler läuft nicht**
Der automatische Scheduler kann deaktiviert sein oder nicht starten.
### 3. **Zeitzonen-Problem**
Die Startzeit kann in einer anderen Zeitzone gespeichert sein als erwartet.
---
## ✅ SOFORT-FIX: Manuell aktivieren
Falls du eine geplante Ausleihe sofort aktivieren möchtest, nutze diesen Befehl auf dem Server:
```bash
docker exec inventarsystem-mongodb mongosh --eval "
db.ausleihungen.updateOne(
{_id: ObjectId('BOOKING_ID_HIER')},
{\$set: {Status: 'active', LastUpdated: new Date()}}
)" Inventarsystem
```
Ersetze `BOOKING_ID_HIER` mit der ID deiner Ausleihe.
---
## 🔧 DEBUG: Alle geplanten Ausleihen anschauen
Führe auf deinem Server aus:
```bash
docker exec inventarsystem-mongodb mongosh --eval "
db.ausleihungen.find({Status: 'planned'}).pretty()
" Inventarsystem
```
Das zeigt dir:
- **Start**: Das gespeicherte Startdatum/Uhrzeit
- **Periode**: Die Schulstunde (falls verwendet)
- **Erstellungs-Zeit vs. aktuelle Zeit**: Vergleich
---
## 🎯 PROBLEM 1: Falsches Datum beim Erstellen
**Symptom**: Start-Zeit ist in der Zukunft (z.B. morgen statt heute)
**Lösung**:
1. In der Kalender-Ansicht das **richtige Datum** auswählen
2. Die **aktuelle Uhrzeit** für den Beginn nutzen (nicht eine zukünftige Zeit)
3. Für Schulstunden: Stelle sicher die **heutige Schulstunde** zu wählen
---
## 🎯 PROBLEM 2: Scheduler läuft nicht
**Symptom**: Keine Logs über Termin-Aktivierungen
**Überprüfung**:
```bash
# 1. Container-Logs anschauen
docker logs inventarsystem-app | grep -i "scheduler\|appointment\|status update"
# 2. Scheduler ist aktiviert?
docker exec inventarsystem-app python3 -c "
import sys
sys.path.insert(0, '/app/Web')
import settings as cfg
print(f'Scheduler aktiviert: {cfg.SCHEDULER_ENABLED}')
print(f'Scheduler Intervall: {cfg.SCHEDULER_INTERVAL_MIN} Minuten')
"
```
**Lösung wenn deaktiviert**:
- In `config.json` ändern:
```json
"scheduler": {
"enabled": true,
"interval_minutes": 1
}
```
- Container neu starten: `docker-compose restart app`
---
## 🎯 PROBLEM 3: Startzeit als nur-Zeit (kein Datum)
**Symptom**: Start-Zeit zeigt nur "13:30" ohne Datum
**Überprüfung**: In der Datenbank nach `{Start: {$type: "string"}}` suchen statt datetime
**Lösung**: Die Ausleihe muss mit einem vollständigen Datum+Uhrzeit erstellt werden.
---
## 🚀 AUTOMATISCHE AKTIVIERUNG TESTEN
Nach dem Fix: Erstelle eine Ausleihe für:
- **Heute** (das aktuelle Datum)
- **Schulstunde 7** (13:30-14:15)
- Startzeitpunkt sollte **jetzt oder in 1 Minute** sein
Warte 2 Minuten. Der Status sollte von `planned` zu `active` wechseln.
---
## 📊 SCHEDULER STATUS ÜBERPRÜFEN
```bash
# Zeige die letzten 50 Log-Zeilen
docker logs --tail 50 inventarsystem-app | tail -20
# Suche nach "Appointment status update"
docker logs inventarsystem-app 2>/dev/null | grep -i "Appointment status" | tail -5
```
Erwartete Log-Ausgabe:
```
Appointment status update finished: X changed (Y active, Z completed)
```
---
## 🔍 DETAILLIERTE DEBUG-INFOS
Wenn obiges nicht funktioniert, führe dies aus:
```bash
docker exec inventarsystem-app python3 -c "
from pymongo import MongoClient
import datetime
client = MongoClient('mongodb', 27017)
db = client['Inventarsystem']
aus = db['ausleihungen']
print('=== GEPLANTE AUSLEIHEN ===')
for b in aus.find({'Status': 'planned'}).limit(3):
print(f\"Start: {b.get('Start')} | Typ: {type(b.get('Start')).__name__}\")
print(f\"Periode: {b.get('Period')} | User: {b.get('User')}\")
print(f\"Jetzt > Start? {datetime.datetime.now() > b.get('Start', datetime.datetime.max)}\")
print()
"
```
---
## 💡 EMPFOHLENE EINSTELLUNG FÜR SCHULEN
In `config.json` verwende diese Werte:
```json
{
"scheduler": {
"enabled": true,
"interval_minutes": 1,
"backup_interval_hours": 24
}
}
```
Dies checkt **jede Minute** ob Termine aktiviert werden sollen - perfekt für Schulstunden.
---
## Weitere Hilfe?
Falls das nicht funktioniert:
1. Schreib mir die **exakte Uhrzeit** wann du die Ausleihe erstellt hast
2. Und wann sie hätte aktiviert werden sollen
3. Zeige mir ein Beispiel aus der DB mit `docker exec inventarsystem-mongodb mongosh...`
-134
View File
@@ -1,134 +0,0 @@
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
"""
Backup-DB.py
Usage:
python Backup-DB.py \
--uri mongodb://user:pass@host:port/ \
--db my_database \
--out /path/to/backup_folder
"""
import os
import csv
import sys
import argparse
from datetime import datetime
from pymongo import MongoClient
def parse_args():
parser = argparse.ArgumentParser(
description="Backup a MongoDB database to CSV files (one per collection)."
)
parser.add_argument(
"--uri", "-u",
required=True,
help="MongoDB URI, e.g. mongodb://user:pass@host:port/"
)
parser.add_argument(
"--db", "-d",
required=True,
help="Name of the database to back up"
)
parser.add_argument(
"--out", "-o",
default=f"mongo_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
help="Output directory (will be created if it doesn't exist)"
)
return parser.parse_args()
def flatten_dict(d, parent_key="", sep="."):
"""
Flatten nested dicts into a single level, concatenating keys with sep.
Lists are left as-is (converted to string later).
"""
items = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[new_key] = v
return items
def export_collection(db, coll_name, out_dir):
coll = db[coll_name]
cursor = coll.find()
docs = list(cursor)
if not docs:
print(f"[{coll_name}] No documents found, skipping.")
return
# Flatten and collect all keys
all_keys = set()
flat_docs = []
for doc in docs:
# Convert ObjectId and other non-serializable types
doc = {k: str(v) for k, v in doc.items()}
flat = flatten_dict(doc)
flat_docs.append(flat)
all_keys.update(flat.keys())
out_file = os.path.join(out_dir, f"{coll_name}.csv")
with open(out_file, "w", newline="", encoding="utf-8") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=sorted(all_keys))
writer.writeheader()
for flat in flat_docs:
writer.writerow(flat)
print(f"[{coll_name}] Exported {len(flat_docs)} docs → {out_file}")
def main():
args = parse_args()
# Create output directory
try:
os.makedirs(args.out, exist_ok=True)
print(f"Backing up database '{args.db}''{args.out}'")
except PermissionError:
print(f"Error: Permission denied when creating directory {args.out}")
print("Try running with sudo or to a different output directory")
return 1
# Verify write permissions
test_file = os.path.join(args.out, ".write_test")
try:
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
except (IOError, PermissionError) as e:
print(f"Error: Cannot write to output directory {args.out}")
print(f"Reason: {str(e)}")
print("Try running with sudo or to a different output directory")
return 1
try:
# Connect to MongoDB
client = MongoClient(args.uri)
# Test connection
client.server_info()
db = client[args.db]
# Export each collection
for coll_name in db.list_collection_names():
export_collection(db, coll_name, args.out)
print("Backup complete.")
return 0
except Exception as e:
print(f"Error: {str(e)}")
return 1
if __name__ == "__main__":
sys.exit(main())
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""
Backup-Invoices.py
Exports all invoice records stored in ausleihungen.InvoiceData to dedicated
archive files (JSONL + CSV + metadata).
Usage:
python Backup-Invoices.py \
--uri mongodb://localhost:27017/ \
--db Inventarsystem \
--out /var/backups/invoice-archive/invoices-2026-04-06_12-00-00
"""
import argparse
import csv
import json
import os
import sys
from datetime import datetime
from bson import ObjectId
from pymongo import MongoClient
def parse_args():
parser = argparse.ArgumentParser(
description="Export invoice records from ausleihungen to JSONL and CSV."
)
parser.add_argument("--uri", "-u", required=True, help="MongoDB URI")
parser.add_argument("--db", "-d", required=True, help="MongoDB database name")
parser.add_argument(
"--out",
"-o",
required=True,
help=(
"Output file base path without extension, e.g. "
"/var/backups/invoice-archive/invoices-2026-04-06_12-00-00"
),
)
return parser.parse_args()
def to_jsonable(value):
if isinstance(value, ObjectId):
return str(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {k: to_jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [to_jsonable(v) for v in value]
return value
def flatten_dict(data, parent_key="", sep="."):
items = {}
for key, value in data.items():
new_key = f"{parent_key}{sep}{key}" if parent_key else str(key)
if isinstance(value, dict):
items.update(flatten_dict(value, new_key, sep=sep))
elif isinstance(value, list):
items[new_key] = json.dumps(value, ensure_ascii=False)
else:
items[new_key] = value
return items
def normalize_invoice_record(doc):
invoice_data = doc.get("InvoiceData") or {}
record = {
"borrow_id": str(doc.get("_id", "")),
"borrow_status": doc.get("Status", ""),
"borrow_user": doc.get("User", ""),
"borrow_item": doc.get("Item", ""),
"borrow_start": doc.get("Start"),
"borrow_end": doc.get("End"),
"invoice_number": invoice_data.get("invoice_number", ""),
"invoice_amount": invoice_data.get("amount"),
"invoice_amount_text": invoice_data.get("amount_text", ""),
"invoice_damage_reason": invoice_data.get("damage_reason", ""),
"invoice_created_at": invoice_data.get("created_at"),
"invoice_created_by": invoice_data.get("created_by", ""),
"invoice_borrower": invoice_data.get("borrower", ""),
"invoice_item_id": invoice_data.get("item_id", ""),
"invoice_item_name": invoice_data.get("item_name", ""),
"invoice_item_code": invoice_data.get("item_code", ""),
"invoice_mark_destroyed": invoice_data.get("mark_destroyed", False),
"invoice_status_before": invoice_data.get("status_before_invoice", ""),
"invoice_raw": invoice_data,
}
return to_jsonable(record)
def write_jsonl(path, records):
with open(path, "w", encoding="utf-8") as handle:
for row in records:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
def write_csv(path, records):
if not records:
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(["info"])
writer.writerow(["no invoice records found"])
return
flat_records = [flatten_dict(row) for row in records]
fieldnames = sorted({key for row in flat_records for key in row.keys()})
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in flat_records:
writer.writerow(row)
def write_metadata(path, db_name, out_base, count):
payload = {
"db": db_name,
"exported_at": datetime.utcnow().isoformat() + "Z",
"record_count": count,
"files": {
"jsonl": out_base + ".jsonl",
"csv": out_base + ".csv",
},
"schema_note": "Source is ausleihungen.InvoiceData",
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def main():
args = parse_args()
out_base = args.out
out_dir = os.path.dirname(out_base) or "."
try:
os.makedirs(out_dir, exist_ok=True)
except Exception as exc:
print(f"Error: cannot create output directory {out_dir}: {exc}")
return 1
client = None
try:
client = MongoClient(args.uri)
client.server_info()
db = client[args.db]
ausleihungen = db["ausleihungen"]
cursor = ausleihungen.find({"InvoiceData": {"$exists": True, "$ne": None}})
records = [normalize_invoice_record(doc) for doc in cursor]
jsonl_path = out_base + ".jsonl"
csv_path = out_base + ".csv"
meta_path = out_base + ".meta.json"
write_jsonl(jsonl_path, records)
write_csv(csv_path, records)
write_metadata(meta_path, args.db, out_base, len(records))
print(f"Invoice backup complete: {len(records)} records")
print(f"JSONL: {jsonl_path}")
print(f"CSV: {csv_path}")
print(f"META: {meta_path}")
return 0
except Exception as exc:
print(f"Error: {exc}")
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
+42 -1
View File
@@ -14,7 +14,7 @@ Die optimierte Multi-Tenant-Architektur unterstützt **mehrere isolierte Instanz
└─────────────────────────────────────────────────────────────┘
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ App :8001 │ │ App :8002 │ │ App :8003
│ App :10000 │ │ App :10002 │ │ App :10004
│ schule1 │ │ schule2 │ │ schule3 │
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
│ 20 Users │ │ 20 Users │ │ 20 Users │
@@ -452,3 +452,44 @@ Für Support: Siehe Legal/LICENSE
---
**Version**: 1.0 | **Letzte Aktualisierung**: 2026-04-17 | **Kompatibilität**: Python 3.11+, MongoDB 7.0+, Redis 7+
## Tenant Management Operationen (manage-tenant.sh)
Um einzelne Tenants im Multi-Tenant-Umfeld im laufenden Betrieb und ohne globale Downtime zu verwalten, kann das neue CLI-Skript `manage-tenant.sh` verwendet werden.
### 1. Neuen Tenant hinzufügen
Initialisiert die MongoDB-Datenbankstruktur isoliert für einen neuen Tenant und legt initiale Admin-Zugangsdaten an.
```bash
./manage-tenant.sh add <tenant_id>
```
### 2. Bestimmten Tenant neu starten (Soft-Restart)
Erzwingt sofortigen Logout und einen Cache/Session-Reset für die Nutzer *eines spezifischen* Tenants, ohne andere laufende Instanzen zu beeinträchtigen. Ideal bei Konfigurationsänderungen oder feststeckenden Sessions.
```bash
./manage-tenant.sh restart-tenant <tenant_id>
```
### 3. Tenant sicher entfernen
Löscht die dedizierte MongoDB-Datenbank des gewählten Tenants vollständig (erfordert Bestätigung).
```bash
./manage-tenant.sh remove <tenant_id>
```
### 4. Globale Operationen
```bash
# Zeigt alle aktiven isolierten Tenant-Datenbanken an
./manage-tenant.sh list
# Führt einen Zero-Downtime Rolling-Restart aller Application-Container durch
./manage-tenant.sh restart-all
```
---
## Aktuelle UI- & Funktionsoptimierungen (Release April 2026)
Neben der Docker-Auslagerung wurden spezifische Caching-, Parsing-, und DOM-Tricks integriert, die das Setup weiter entschlacken:
* **DOM Array Slicing für Bilder:** Bei großen Beständen (hunderte Artikel) rendert der Client im Listen/Kachel-Modus künftig nur noch das primäre Bild (`slice(0, 1)`), was den DOM-Memory-Footprint drastisch reduziert und das Einfrieren von Browsern verhindert.
* **Auto-Ingestion von Excel-Filtern:** Der Excel-Importer prüft nun dynamisch neue `categories/filter`, die noch nicht in der Datenbank existieren, und speichert sie direkt in die MongoDB `filter_presets`-Kollektion (Zero-Config für Administratoren).
* **Responsive UI Synchronisierung:** Die Standardansicht (`main.html`) der Smartphones wurde CSS-technisch exakt an das skalierungsfähigere Profil der Admin-Mobile-Ansicht (`main_admin.html`) angeglichen.
+26
View File
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
---
## Schul-Konfiguration pro Tenant
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
```json
{
"tenants": {
"schule1": {
"modules": {
"library": { "enabled": true },
"student_cards": { "enabled": false }
}
},
"schule2": {
"modules": {
"library": { "enabled": false }
}
}
}
}
```
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
---
## Support & Debugging
**Fragen?**
+128
View File
@@ -0,0 +1,128 @@
# Multi-Tenant Python Management API
This document explains how the multi-tenant architecture isolates data within Python, what the return values are, and how developers can build internal administrative scripts using native Python instead of the Docker CLI.
## 1. Architectural Concept
In the system, each "tenant" is essentially a dedicated MongoDB database identified by a dynamically generated string based on a subdomain or header (`inventar_<tenant_id>`).
App containers share a connection pool using `pymongo.MongoClient`, and requests are routed to specific databases dynamically based on the current Flask `g.tenant_context`.
All MongoDB administrative tasks (creating tenants, restarting apps, fetching lists) are done via standard MongoDB Python drivers because the core multi-tenancy happens at the **database level**.
## 2. Managing Tenants via Python
If you want to perform multi-tenant administrative operations without traversing through `manage-tenant.sh`, you can execute native Python scripts connecting to the system's `MongoClient`.
### Basic Connection Boilerplate
Whenever automating an administrative task in Python, you simply need to connect to MongoDB using the properties defined in `settings.py`.
```python
import sys
import os
# Append Web folder so we can access configuration
sys.path.insert(0, '/app/Web')
import settings
from pymongo import MongoClient
# Establish connection pooling
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
```
### A. Adding a New Tenant (Database Initialization)
A new tenant database isnt provisioned until the first actual data insert happens. We trigger this manually by creating an `admin` user for them.
**Operation:**
```python
def create_tenant(tenant_id, admin_password="hashed_password_here"):
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
db = client[db_name]
# MongoDB creates the DB automatically on first insert
result = db.users.insert_one({
'username': 'admin',
'password': admin_password,
'role': 'admin'
})
return result.inserted_id # Returns the BSON ObjectId of the new user
```
### B. List Active Tenants
To find out how many isolated tenants have active databases, you query the raw `MongoClient` for all databases and search for your configured MongoDB prefix (default: `inventar_`).
**Operation:**
```python
def list_tenants():
prefix = f"{settings.MONGODB_DB}_"
# Returns a Python list of string database names
all_dbs = client.list_database_names()
# Filter and strip the prefix to return just the tenant_ids
active_tenants = [d.replace(prefix, "") for d in all_dbs if d.startswith(prefix)]
return active_tenants # e.g., ['schule1', 'schule2', 'test']
```
### C. Soft-Restarting a Tenant (Invalidating Sessions)
"Restarting" a single tenant means signing out all of their users and forcing an application refresh. Because Session data is coupled to the tenant database, dropping their `sessions` collection achieves an instant sign-out.
**Operation:**
```python
def restart_tenant(tenant_id):
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
db = client[db_name]
# Drops the collection. All active user cookies immediately become invalid.
result = db.sessions.drop()
return result # Returns None. Raises PyMongoError if connection fails.
```
### D. Removing a Tenant Completely (Wipe Data)
If a tenant is removed from the service or their lease expires, you can permanently obliterate their data container footprint.
**Operation:**
```python
def remove_tenant(tenant_id):
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
# Erases the isolated database. Can't be undone.
client.drop_database(db_name)
return True # Returns True. Raises PyMongoError if connection fails.
```
## 3. Resolving Context Inside Flask (app.py)
If you are building custom application endpoints inside `Web/app.py`, you shouldn't use the direct MongoDB `client` manually. Instead, you rely on the built-in Flask context manager (`Web/tenant.py`) to give you the correct isolated scope.
### The `get_tenant_db()` function
Every route must use `get_tenant_db(client)` to ensure users can only ever access their own school/domain's database.
```python
from pymongo import MongoClient
import settings
from tenant import get_tenant_db
# Example Route
@app.route('/api/items')
def get_items():
# 1. Establish/reuse pooling connection
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
# 2. Get the dynamically routed DB for THIS user
# (Based on Nginx Subdomain or X-Tenant-Id header)
db = get_tenant_db(client)
# 3. Runs query solely on `inventar_schule1.items`
items = list(db.items.find())
return items # List of BSON Dictionaries
```
**What it returns internally:**
The `get_tenant_db` function queries `g.tenant_context` inside Flask, calculates the database name from the subdomain, and returns a live `pymongo.database.Database` object.
This ensures that scaling is extremely cheap on resources because 1 Application Container connects to 100 separate Tenant Databases using just 1 shared `MongoClient` pool.
+8 -18
View File
@@ -45,8 +45,6 @@ Das Inventarsystem stellt folgende Wartungsskripte bereit:
| `stop.sh` | Dienste stoppen |
| `restart.sh` | Dienste neu starten |
| `build-nuitka.sh` | Standalone-Build der App mit Nuitka erstellen |
| `init-admin.sh` | Initialisiert Admin-User wenn DB leer ist |
| `create-user.sh` | Erstellt neue User per Kommandozeile |
| `Backup-DB.py` | Manuelles DB-Backup |
| `restore.sh` | Backup wiederherstellen |
| `manage-version.sh` | Versionssteuerung |
@@ -163,7 +161,7 @@ sudo ./install.sh --cleanup-old-remove-cron
Das System läuft produktiv Docker-first. Deployments und Updates erfolgen über Release-Artefakte (Build-only), nicht über Quellcode-Pulls.
- Laufzeit-Stack: [docker-compose.yml](docker-compose.yml)
- Laufzeit-Stack: [docker-compose-multitenant.yml](docker-compose-multitenant.yml)
- Build-Pipeline: [Dockerfile](Dockerfile)
- Release-Pipeline: [.github/workflows/release-docker.yml](.github/workflows/release-docker.yml)
- Frontend/Reverse Proxy: Nginx (Container)
@@ -237,7 +235,7 @@ docker compose logs -f nginx
### Persistente Daten
Die Volumes sind in [docker-compose.yml](docker-compose.yml) definiert:
Die Volumes sind in [docker-compose-multitenant.yml](docker-compose-multitenant.yml) definiert:
- MongoDB Daten
- Uploads / Thumbnails / Previews / QRCodes
@@ -286,27 +284,19 @@ https://[SERVER-IP]
Wenn die Datenbank leer ist (beim ersten Start), erstellen Sie einen Admin-User:
**Option 1: Automatisiert beim Start**
```bash
./init-admin.sh
```
Mit benutzerdefinierten Daten:
```bash
./init-admin.sh myadmin mypassword123 Max Mustermann
```
**Option 2: Interaktiv**
**Option 1: Interaktiv**
```bash
cd Web && python3 generate_user.py
```
**Option 3: Per Kommandozeile**
**Option 2: Multitenant-Tenant erstellen**
```bash
./create-user.sh admin password123456 Admin User --admin
sudo ./manage-tenant.sh add <tenant-name> <port>
```
**Option 4: Direkt in MongoDB (Notlösung)**
Dieser Weg erzeugt beim Anlegen eines neuen Tenants einen Standard-Admin für den Tenant.
**Option 3: Direkt in MongoDB (Notlösung)**
```bash
docker exec inventarsystem-mongodb mongosh --eval "
db.users.insertOne({
+173 -10
View File
@@ -50,6 +50,7 @@ import socket
import io
import html
import logging
from logging.handlers import RotatingFileHandler
import secrets
import importlib
try:
@@ -76,10 +77,19 @@ from data_protection import (
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import settings as cfg
from settings import MongoClient
from tenant import get_tenant_context
app = Flask(__name__, static_folder='static') # Correctly set static folder
app.logger.setLevel(logging.WARNING)
app.logger.setLevel(logging.DEBUG)
if not os.path.exists(cfg.LOGS_FOLDER):
os.makedirs(cfg.LOGS_FOLDER, exist_ok=True)
log_file_path = os.path.join(cfg.LOGS_FOLDER, 'application.log')
file_handler = RotatingFileHandler(log_file_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
app.logger.handlers = []
app.logger.addHandler(file_handler)
app.secret_key = cfg.SECRET_KEY
app.debug = cfg.DEBUG
app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER
@@ -339,6 +349,28 @@ def _enforce_user_permissions():
return None
@app.before_request
def _enforce_active_session_user():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
username = session.get('username')
if not username:
return None
user = us.get_user(username)
if user:
return None
session.clear()
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login'))
def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
@@ -369,12 +401,60 @@ def _is_library_module_path(path):
)
return path.startswith(library_prefixes)
def _is_inventory_module_path(path):
"""Return True when the current request path belongs to the inventory module."""
if not path:
return False
if path == '/' or path.startswith('/home'):
return True
inventory_prefixes = (
'/scanner',
'/inventory',
'/upload_admin',
'/manage_filters',
'/manage_locations',
'/admin_borrowings',
'/admin_damaged_items',
'/admin/borrowings',
'/admin/damaged_items',
'/terminplan',
)
return path.startswith(inventory_prefixes)
@app.before_request
def _enforce_module_access():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static') or request.path.startswith('/api/'):
return None
if not cfg.LIBRARY_MODULE_ENABLED and _is_library_module_path(request.path):
return "Bibliotheks-Modul ist deaktiviert.", 403
if not cfg.INVENTORY_MODULE_ENABLED and _is_inventory_module_path(request.path):
return "Inventar-Modul ist deaktiviert.", 403
def _get_current_module(path):
"""Resolve the active UI module for navbar separation."""
if cfg.LIBRARY_MODULE_ENABLED and _is_library_module_path(path):
session['last_module'] = 'library'
return 'library'
return 'inventory'
if cfg.INVENTORY_MODULE_ENABLED and _is_inventory_module_path(path):
session['last_module'] = 'inventory'
return 'inventory'
last_module = session.get('last_module')
if last_module == 'library' and cfg.LIBRARY_MODULE_ENABLED:
return 'library'
if last_module == 'inventory' and cfg.INVENTORY_MODULE_ENABLED:
return 'inventory'
# Default fallback: prefer inventory if enabled, otherwise library, else inventory
if cfg.INVENTORY_MODULE_ENABLED:
return 'inventory'
return 'library' if cfg.LIBRARY_MODULE_ENABLED else 'inventory'
def _get_current_user_permissions():
@@ -1070,6 +1150,15 @@ def inject_version():
client.close()
current_module = _get_current_module(request.path)
current_tenant_db = MONGODB_DB
current_tenant_id = None
try:
ctx = get_tenant_context()
if ctx:
current_tenant_db = ctx.db_name or current_tenant_db
current_tenant_id = ctx.tenant_id
except Exception:
current_tenant_db = MONGODB_DB
return {
'APP_VERSION': APP_VERSION,
@@ -1077,11 +1166,14 @@ def inject_version():
'csrf_token': csrf_token,
'CURRENT_MODULE': current_module,
'school_periods': SCHOOL_PERIODS,
'inventory_module_enabled': cfg.INVENTORY_MODULE_ENABLED,
'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED,
'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED,
'is_admin': is_admin,
'unread_notification_count': unread_notification_count,
'current_permissions': current_permissions,
'current_tenant_db': current_tenant_db,
'current_tenant_id': current_tenant_id,
'permission_action_options': PERMISSION_ACTION_OPTIONS,
'permission_page_options': PERMISSION_PAGE_OPTIONS,
'permission_presets': us.get_permission_preset_definitions(),
@@ -1291,9 +1383,17 @@ def _initialize_scheduler():
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception:
pass # If we can't clean up, continue anyway
except Exception as e:
app.logger.warning(f"Could not clean up scheduler lock file: {e}")
# Always try to remove lock file on startup (extra safety)
try:
if os.path.exists(scheduler_lock_path):
os.remove(scheduler_lock_path)
app.logger.info("Scheduler lock file removed on startup.")
except Exception as e:
app.logger.warning(f"Could not remove scheduler lock file on startup: {e}")
try:
# Try to create the lock file - only succeeds if it doesn't exist
lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
@@ -1302,7 +1402,7 @@ def _initialize_scheduler():
except FileExistsError:
should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
@@ -2063,6 +2163,11 @@ def _upload_excel_items(scope='inventory'):
validation_warnings = []
reserved_codes = set()
known_locations = set(it.get_predefined_locations())
known_filters = {
1: set(it.get_predefined_filter_values(1)),
2: set(it.get_predefined_filter_values(2)),
3: set(it.get_predefined_filter_values(3))
}
def is_code_available(candidate_code):
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
@@ -2114,8 +2219,19 @@ def _upload_excel_items(scope='inventory'):
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
for f in filter1:
if f and f not in known_filters[1]:
validation_warnings.append((row_number, f'Kategorie 1 "{f}" wird neu angelegt'))
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
for f in filter2:
if f and f not in known_filters[2]:
validation_warnings.append((row_number, f'Kategorie 2 "{f}" wird neu angelegt'))
filter3 = _excel_list(val('filter3'))
for f in filter3:
if f and f not in known_filters[3]:
validation_warnings.append((row_number, f'Kategorie 3 "{f}" wird neu angelegt'))
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
raw_year = val('anschaffungsjahr')
@@ -2217,6 +2333,21 @@ def _upload_excel_items(scope='inventory'):
it.add_predefined_location(row['ort'])
known_locations.add(row['ort'])
for f in row.get('filter1', []):
if f and f not in known_filters[1]:
it.add_predefined_filter_value(1, f)
known_filters[1].add(f)
for f in row.get('filter2', []):
if f and f not in known_filters[2]:
it.add_predefined_filter_value(2, f)
known_filters[2].add(f)
for f in row.get('filter3', []):
if f and f not in known_filters[3]:
it.add_predefined_filter_value(3, f)
known_filters[3].add(f)
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
row_created_ids = []
@@ -2659,6 +2790,13 @@ def home():
if 'username' not in session:
flash('Bitte mit registriertem Konto anmelden!', 'error')
return redirect(url_for('login'))
if not cfg.INVENTORY_MODULE_ENABLED:
if cfg.LIBRARY_MODULE_ENABLED:
return redirect(url_for('library_view'))
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
elif not us.check_admin(session['username']):
return render_template(
'main.html',
@@ -2691,6 +2829,13 @@ def home_admin():
if 'username' not in session:
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'))
if not cfg.INVENTORY_MODULE_ENABLED:
if cfg.LIBRARY_MODULE_ENABLED:
return redirect(url_for('library_admin'))
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
if 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'))
@@ -3975,13 +4120,22 @@ def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
ctx = get_tenant_context()
current_tenant_id = ctx.tenant_id if ctx else None
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
app.logger.info(f"Login attempt: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
app.logger.info(f"Debug login context: headers={dict(request.headers)} tenant_config={ctx.config if ctx else None} remote_addr={request.remote_addr} host={request.host}")
app.logger.info(f"Raw login payload: username={username!r} password={password!r}")
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
if not username or not password:
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={request.remote_addr}")
flash('Bitte alle Felder ausfüllen', 'error')
return redirect(url_for('login'))
user = us.check_nm_pwd(username, password)
if user:
app.logger.info(f"Login success: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
session['username'] = username
is_admin_user = bool(user.get('Admin', False))
session['admin'] = is_admin_user
@@ -4002,6 +4156,7 @@ def login():
else:
return redirect(url_for('home'))
else:
app.logger.warning(f"Login failed: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
flash('Ungültige Anmeldedaten', 'error')
get_flashed_messages()
return render_template('login.html')
@@ -6526,11 +6681,15 @@ def zurueckgeben(id):
def get_filter():
"""
API endpoint to retrieve available item filters/categories.
Returns:
dict: Dictionary of available filters
dict: Dictionary of available filters by filter field
"""
return it.get_filters()
return jsonify({
'filter1': it.get_primary_filters(),
'filter2': it.get_secondary_filters(),
'filter3': it.get_tertiary_filters(),
})
@app.route('/get_ausleihung_by_item/<id>')
@@ -7055,7 +7214,7 @@ def register():
name = (request.form.get('name') or '').strip()
last_name = (request.form.get('last-name') or '').strip()
# Always generate username from abbreviation logic and auto-extend on collisions.
# Generate a username from the first 2 letters of first and last name.
username = us.build_unique_username_from_name(name, last_name)
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
@@ -10753,6 +10912,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
# PUSH NOTIFICATION API ENDPOINTS
# ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push():
"""
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
import argparse
import csv
import datetime
import json
import os
import sys
try:
from bson import json_util
except ImportError:
json_util = None
try:
from pymongo import MongoClient
except ImportError:
print('Error: pymongo is required to run invoice backups.', file=sys.stderr)
sys.exit(1)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, '..', 'config.json'))
DEFAULT_ARCHIVE_DIR = '/var/backups/invoice-archive'
DEFAULT_KEEP_DAYS = 3650
def load_config():
config = {}
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = json.load(f)
except Exception:
pass
return config
def resolve_mongo_settings(args):
config = load_config()
mongodb = config.get('mongodb', {}) if isinstance(config, dict) else {}
host = os.getenv('INVENTAR_MONGODB_HOST') or args.mongo_host or mongodb.get('host') or 'localhost'
port = os.getenv('INVENTAR_MONGODB_PORT') or args.mongo_port or mongodb.get('port') or 27017
db_name = os.getenv('INVENTAR_MONGODB_DB') or args.db_name or mongodb.get('db') or 'Inventarsystem'
uri = args.mongo_uri
if isinstance(port, str) and port.strip().isdigit():
port = int(port.strip())
return host, int(port), db_name, uri
def format_csv_value(value):
if value is None:
return ''
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, datetime.datetime):
return value.isoformat()
if isinstance(value, (list, dict)):
return json.dumps(value, ensure_ascii=False)
return str(value)
def normalize_doc_for_json(doc):
if json_util is not None:
return json.loads(json_util.dumps(doc, default=json_util.default))
return doc
def build_csv_row(document):
invoice_data = document.get('InvoiceData') or {}
corrections = document.get('InvoiceCorrections') or []
return {
'invoice_number': invoice_data.get('invoice_number', ''),
'borrow_id': str(document.get('_id', '')),
'status_before_invoice': invoice_data.get('status_before_invoice', '') or document.get('Status', ''),
'borrower': document.get('User', '') or invoice_data.get('borrower', ''),
'item': document.get('Item', ''),
'amount': invoice_data.get('amount', ''),
'currency': invoice_data.get('currency', 'EUR'),
'created_at': format_csv_value(invoice_data.get('created_at')),
'paid': invoice_data.get('paid', False),
'paid_at': format_csv_value(invoice_data.get('paid_at')),
'invoice_reason': invoice_data.get('damage_reason', ''),
'corrections_count': len(corrections) if isinstance(corrections, list) else 0,
'corrections': json.dumps(corrections, ensure_ascii=False) if corrections else '',
}
def write_jsonl(path, cursor):
with open(path, 'w', encoding='utf-8') as f:
for document in cursor:
if json_util is not None:
line = json_util.dumps(document, default=json_util.default)
else:
line = json.dumps(document, default=str, ensure_ascii=False)
f.write(line + '\n')
def write_csv(path, cursor):
fieldnames = [
'invoice_number',
'borrow_id',
'status_before_invoice',
'borrower',
'item',
'amount',
'currency',
'created_at',
'paid',
'paid_at',
'invoice_reason',
'corrections_count',
'corrections',
]
with open(path, 'w', encoding='utf-8', newline='') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for document in cursor:
writer.writerow({k: format_csv_value(v) for k, v in build_csv_row(document).items()})
def write_meta(path, metadata):
with open(path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
def parse_args():
parser = argparse.ArgumentParser(description='Create a legal invoice archive backup from the MongoDB invoice records.')
parser.add_argument('--archive-dir', required=True, help='Directory to write archive files into')
parser.add_argument('--base-name', default=None, help='Base filename prefix for archive files')
parser.add_argument('--mongo-host', default=None, help='MongoDB host override')
parser.add_argument('--mongo-port', type=int, default=None, help='MongoDB port override')
parser.add_argument('--db-name', default=None, help='MongoDB database name override')
parser.add_argument('--mongo-uri', default=None, help='MongoDB connection URI override')
return parser.parse_args()
def main():
args = parse_args()
archive_dir = os.path.abspath(args.archive_dir)
os.makedirs(archive_dir, exist_ok=True)
base_name = args.base_name or f'invoices-{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
jsonl_path = os.path.join(archive_dir, f'{base_name}.jsonl')
csv_path = os.path.join(archive_dir, f'{base_name}.csv')
meta_path = os.path.join(archive_dir, f'{base_name}.meta.json')
host, port, db_name, uri = resolve_mongo_settings(args)
if uri:
client = MongoClient(uri)
else:
client = MongoClient(host, port)
try:
db = client[db_name]
collection = db['ausleihungen']
query = {'InvoiceData.invoice_number': {'$exists': True, '$ne': ''}}
projection = {'InvoiceData': 1, 'InvoiceCorrections': 1, 'User': 1, 'Item': 1, 'Status': 1}
cursor = collection.find(query, projection)
docs = list(cursor)
if not docs:
print('No invoice records found. No archive written.')
return 0
write_jsonl(jsonl_path, docs)
write_csv(csv_path, docs)
metadata = {
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'invoice_count': len(docs),
'archive_files': [os.path.basename(jsonl_path), os.path.basename(csv_path), os.path.basename(meta_path)],
'mongo_db': db_name,
'mongo_host': host,
'mongo_port': port,
'query': query,
}
write_meta(meta_path, metadata)
print(f'Wrote invoice archive: {jsonl_path}')
print(f'Wrote invoice archive CSV: {csv_path}')
print(f'Wrote metadata: {meta_path}')
return 0
finally:
client.close()
if __name__ == '__main__':
sys.exit(main())
-1
View File
@@ -1 +0,0 @@
[2026-04-19 20:20:39,502] ERROR in app: Error during scheduler shutdown: name '__file__' is not defined
+1 -1
View File
@@ -6,13 +6,13 @@ Handles Web Push notifications for users via Service Workers
import os
import json
import datetime
from pymongo import MongoClient
from bson import ObjectId
import requests
import hashlib
import logging
import settings as cfg
from settings import MongoClient
logger = logging.getLogger(__name__)
+2 -1
View File
@@ -13,5 +13,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
+63 -3
View File
@@ -14,6 +14,7 @@ import os
import json
import atexit
from threading import Lock
from flask import has_request_context
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
@@ -73,6 +74,9 @@ DEFAULTS = {
"10": {"start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)"}
},
'modules': {
'inventory': {
'enabled': True
},
'library': {
'enabled': False
},
@@ -130,11 +134,15 @@ PORT = _get(_conf, ['port'], DEFAULTS['port'])
MONGODB_HOST = _get(_conf, ['mongodb', 'host'], DEFAULTS['mongodb']['host'])
MONGODB_PORT = _get(_conf, ['mongodb', 'port'], DEFAULTS['mongodb']['port'])
MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
MONGODB_URI = _get(_conf, ['mongodb', 'uri'], '')
# Optional environment overrides for containerized/runtime deployments.
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
MONGODB_URI = os.getenv('INVENTAR_MONGODB_URI', os.getenv('MONGO_URI', MONGODB_URI))
if isinstance(MONGODB_URI, str):
MONGODB_URI = MONGODB_URI.strip() or None
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
@@ -157,8 +165,37 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
# Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
class _TenantAwareBool:
def __init__(self, module_name, default):
self.module_name = module_name
self.default = bool(default)
def resolve(self):
try:
from tenant import is_tenant_module_enabled
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
except Exception:
return self.default
def __bool__(self):
return self.resolve()
def __int__(self):
return int(self.resolve())
def __str__(self):
return 'True' if self.resolve() else 'False'
def __repr__(self):
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
@@ -222,8 +259,29 @@ class _MongoClientProxy:
return getattr(self._client, name)
def __getitem__(self, name):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client[ctx.db_name]
except Exception:
pass
return self._client[name]
def get_database(self, name=None, *args, **kwargs):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name is None or name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client.get_database(ctx.db_name, *args, **kwargs)
except Exception:
pass
return self._client.get_database(name, *args, **kwargs)
def __enter__(self):
return self
@@ -268,7 +326,9 @@ def MongoClient(*args, **kwargs):
}
client_kwargs.update(kwargs)
if len(args) >= 2 and not explicit_host and not explicit_port:
if MONGODB_URI and len(args) == 0 and not explicit_host and not explicit_port:
mongo_args = (MONGODB_URI,)
elif len(args) >= 2 and not explicit_host and not explicit_port:
mongo_args = args
else:
mongo_args = (host, port)
+78 -19
View File
@@ -21,28 +21,47 @@
--ui-radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--ui-bg: #0f172a;
--ui-bg-accent: #1e293b;
--ui-surface: #1e293b;
--ui-surface-soft: #25314a;
--ui-border: #334155;
--ui-text: #f1f5f9;
--ui-text-muted: #94a3b8;
--ui-title: #ffffff;
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
}
/* Make inputs visible in dark mode */
input, select, textarea {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
:root[data-theme="dark"] {
--ui-bg: #0f172a;
--ui-bg-accent: #1e293b;
--ui-surface: #1e293b;
--ui-surface-soft: #25314a;
--ui-border: #334155;
--ui-text: #f1f5f9;
--ui-text-muted: #94a3b8;
--ui-title: #ffffff;
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
html {
/* Ensure viewport-fit is respected */
viewport-fit: cover;
}
body {
/* Respect safe areas on mobile devices */
position: relative;
}
/* Future-proof: Support for various viewport and safe-area features */
@supports (padding: max(0px)) {
body {
/* Ensure body padding never conflicts with safe areas */
padding: 0;
}
}
/* Make inputs visible in dark mode */
:root[data-theme="dark"] input,
:root[data-theme="dark"] select,
:root[data-theme="dark"] textarea {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
color: var(--ui-text);
@@ -172,11 +191,16 @@ select:focus {
box-shadow: none;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
/* Safe area insets for iPad notches and Dynamic Island */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
}
.navbar-brand {
font-weight: 800;
letter-spacing: 0.02em;
/* Prevent navbar brand from extending into unsafe areas */
word-break: break-word;
}
@media (max-width: 900px) {
@@ -190,6 +214,41 @@ select:focus {
}
}
/* Mobile and tablet safe area handling for navbar */
@media (max-width: 992px) {
.navbar {
/* Ensure navbar spans full width but respects safe areas */
width: 100vw;
margin-left: calc(-50vw + 50%);
/* Safe area padding is already handled by navbar class */
}
.navbar .container-fluid {
width: 100%;
box-sizing: border-box;
/* Ensure padding accounts for safe areas on small screens */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
}
.navbar-collapse {
/* Ensure collapsed navbar respects safe areas */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
}
}
/* iPad-specific landscape and portrait handling */
@media (max-height: 600px) and (orientation: landscape) {
.navbar {
padding-top: max(env(safe-area-inset-top, 0), 4px);
}
.navbar .container-fluid {
padding-bottom: 4px;
}
}
/* Edit and generate buttons with theme-aware text for better visibility */
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-text) !important; /* Override any existing color */
+5 -5
View File
@@ -11,7 +11,7 @@
</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="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<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>
@@ -19,15 +19,15 @@
<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="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<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="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<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="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<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>
@@ -61,7 +61,7 @@
</div>
{% endif %}
<div style="border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:12px;">
<div style="border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface); 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;">
+4 -4
View File
@@ -161,7 +161,7 @@
</div>
<div id="invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div>
<h2 style="margin:0;">Rechnung erstellen</h2>
@@ -174,15 +174,15 @@
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div>
<label for="invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
</div>
<div>
<label for="invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
</div>
<div>
<label for="invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
</div>
<div>
<label for="invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
+3 -3
View File
@@ -17,14 +17,14 @@
</div>
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
<div style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
<div style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
<table id="damage-table" style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:#f8fafc;">
<tr style="background: var(--ui-surface-soft);">
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
+597 -292
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -53,7 +53,7 @@
max-width: 500px;
margin: 50px auto;
padding: 30px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
+237 -1
View File
@@ -208,12 +208,23 @@
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
}
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const items = container.querySelectorAll('.item-card');
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const virtualizationState = window.mobileLibraryVirtualizationState || null;
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
@@ -236,6 +247,229 @@ function setViewMode(mode) {
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer');
if (!container) {
return;
}
if (!window.mobileLibraryVirtualizationState) {
window.mobileLibraryVirtualizationState = {
active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
}
const state = window.mobileLibraryVirtualizationState;
function restoreAllImages() {
state.items.forEach(item => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
const originalSrc = image.dataset.mobileSrc || '';
if (!image.getAttribute('src') && originalSrc) {
image.setAttribute('src', originalSrc);
}
});
}
function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4;
state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || '';
}
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) {
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
image.setAttribute('src', image.dataset.mobileSrc);
}
return;
}
if (image.getAttribute('src')) {
image.removeAttribute('src');
}
});
}
function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) {
endIndex = Math.min(state.items.length, startIndex + 14);
}
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return;
}
state.lastStart = startIndex;
state.lastEnd = endIndex;
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
state.topSpacer.nextSibling.remove();
}
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(state.items[index]);
}
container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0;
let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) {
const height = state.items[index].getBoundingClientRect().height;
if (height > 0) {
measuredHeightSum += height;
measuredCount += 1;
}
}
if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount;
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
}
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex);
}
function scheduleWindowRender() {
if (!state.active || state.framePending) {
return;
}
state.framePending = true;
requestAnimationFrame(() => {
state.framePending = false;
renderVisibleWindow();
});
}
function activateVirtualization() {
if (state.active) {
return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (e.target === this) this.style.display = 'none';
});
});
initMobileWindowedLibraryLoading();
});
function displayLibraryItemDetail(item) {
+8 -8
View File
@@ -52,7 +52,7 @@
}
.summary-card {
background: #fff;
background: var(--ui-surface);
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 14px 16px;
@@ -85,11 +85,11 @@
padding: 11px 14px;
border: 1px solid #d6dde6;
border-radius: 12px;
background: #fff;
background: var(--ui-surface);
}
.panel {
background: #fff;
background: var(--ui-surface);
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 18px;
@@ -120,7 +120,7 @@
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: #f8fafc;
background: var(--ui-surface-soft);
}
.library-table tr:hover td {
@@ -408,7 +408,7 @@
</div>
<div id="damage-invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div>
<h2 style="margin:0;">Rechnung erstellen</h2>
@@ -421,15 +421,15 @@
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div>
<label for="damage-invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
</div>
<div>
<label for="damage-invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
</div>
<div>
<label for="damage-invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
</div>
<div>
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
+2 -2
View File
@@ -40,7 +40,7 @@
}
.invoice-card {
background: #fff;
background: var(--ui-surface);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 18px;
@@ -65,7 +65,7 @@
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: #f8fafc;
background: var(--ui-surface-soft);
}
.invoice-table tr:hover td {
+13 -13
View File
@@ -18,7 +18,7 @@
.library-header h1 {
margin: 0 0 5px 0;
font-size: 2em;
color: #333;
color: var(--ui-text);
}
.library-header p {
@@ -48,7 +48,7 @@
/* Scanner panel */
.library-scan-panel {
background: #ffffff;
background: var(--ui-surface);
border: 1px solid #d9dde4;
border-radius: 8px;
padding: 14px;
@@ -68,7 +68,7 @@
border: 1px solid #d0d7e2;
border-radius: 6px;
font-size: 14px;
background: #fff;
background: var(--ui-surface);
}
.library-scan-status {
@@ -95,7 +95,7 @@
border: 1px solid #d9dde4;
border-radius: 8px;
overflow: hidden;
background: #fff;
background: var(--ui-surface);
max-width: 460px;
}
@@ -111,7 +111,7 @@
border-radius: 6px;
cursor: pointer;
font-weight: 500;
color: #333;
color: var(--ui-text);
transition: all 0.2s ease;
}
@@ -160,7 +160,7 @@
.filter-item label {
font-weight: 600;
font-size: 0.9em;
color: #333;
color: var(--ui-text);
margin-bottom: 6px;
}
@@ -206,7 +206,7 @@
}
.library-items-table thead {
background: #f8f9fa;
background: var(--ui-surface-soft);
border-bottom: 2px solid #ddd;
}
@@ -214,7 +214,7 @@
padding: 14px 16px;
text-align: left;
font-weight: 600;
color: #333;
color: var(--ui-text);
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -228,7 +228,7 @@
}
.library-items-table tbody tr:hover {
background: #f8f9fa;
background: var(--ui-surface-soft);
}
.library-items-table tbody tr:last-child td {
@@ -238,7 +238,7 @@
/* Table cells */
.table-title {
font-weight: 600;
color: #333;
color: var(--ui-text);
}
.table-status {
@@ -320,7 +320,7 @@
}
.modal-content {
background-color: #fff;
background-color: var(--ui-surface);
border-radius: 8px;
padding: 20px;
max-width: 900px;
@@ -373,7 +373,7 @@
}
.close:hover {
color: #333;
color: var(--ui-text);
}
/* Mobile responsive */
@@ -494,7 +494,7 @@
</div>
<div class="filter-buttons">
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: #333;">Zurücksetzen</button>
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button>
</div>
</div>
+1 -1
View File
@@ -337,7 +337,7 @@
color: #0d6efd;
}
.license-content {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 20px 30px;
border-radius: 0 0 5px 5px;
border: 1px solid #dee2e6;
+9
View File
@@ -14,6 +14,15 @@
<div class="row justify-content-center">
<div class="col-md-6">
<h2 class="text-center">Login</h2>
{% if current_tenant_id or current_tenant_db %}
<div class="alert alert-info text-center small mb-4" role="alert">
{% if current_tenant_id %}
Aktiver Tenant: <strong>{{ current_tenant_id }}</strong> &middot; Datenbank: <strong>{{ current_tenant_db }}</strong>
{% else %}
Aktive Datenbank: <strong>{{ current_tenant_db }}</strong>
{% endif %}
</div>
{% endif %}
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
+1 -1
View File
@@ -217,7 +217,7 @@
h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
color: var(--ui-text);
}
.filter-controls {
+144 -42
View File
@@ -20,7 +20,7 @@
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--ui-border);
background: #f8fafc;
background: var(--ui-surface-soft);
}
.calendar-header button {
@@ -36,7 +36,7 @@
}
.calendar-header button:hover {
background: #f1f5f9;
background: var(--ui-bg);
}
.calendar-header span {
@@ -80,7 +80,7 @@
}
.cal-cell:not(.header):not(.empty):hover {
background: #f1f5f9;
background: var(--ui-bg);
}
.cal-cell.empty {
@@ -115,7 +115,7 @@
.calendar-day-details {
border-top: 1px solid #e7edf5;
padding: 12px;
background: #f8fafc;
background: var(--ui-surface-soft);
display: none;
}
@@ -321,7 +321,7 @@
border: 1px solid #dc3545 !important;
}
</style>
<div class="container">
<div class="container admin-content-container">
<!-- Conflict warning banner (populated by JS) -->
<div id="conflict-banner" style="display:none; background:#fff3cd; border:1px solid #ffc107; border-left:4px solid #fd7e14; color:#856404; padding:12px 16px; border-radius:4px; margin-bottom:16px; position:relative;">
<strong>⚠ Reservierungskonflikt</strong>
@@ -665,18 +665,30 @@
// Set up schedule form submission
setupScheduleFormSubmission();
// Fetch user status to get current username
fetch('/user_status')
.then(response => response.json())
.then(data => {
currentUsername = data.username;
// Fetch user status to get current username and full filter lists
Promise.all([
fetch('/user_status').then(response => response.json()),
fetch('/get_filter').then(response => response.json()),
])
.then(([userData, filterData]) => {
currentUsername = userData.username;
if (filterData.filter1) {
allFilter1Values = new Set(filterData.filter1.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter2) {
allFilter2Values = new Set(filterData.filter2.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter3) {
allFilter3Values = new Set(filterData.filter3.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
// Now load items with username information
loadItems();
// Setup card images display after a short delay to ensure items are loaded
setTimeout(setupCardImagesDisplay, 500);
})
.catch(error => {
console.error('Error fetching user status:', error);
console.error('Error fetching user status or filters:', error);
loadItems(); // Load items anyway in case of error
});
@@ -721,6 +733,10 @@
filter3: []
};
let allFilter1Values = new Set();
let allFilter2Values = new Set();
let allFilter3Values = new Set();
// Add this line to define allItems globally
let allItems = [];
@@ -904,7 +920,7 @@
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
const imagesHtml = item.Images ? item.Images.map((file, index) => {
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((file, index) => {
// Get file with correct case handling
const image = file;
@@ -1005,14 +1021,14 @@
card.innerHTML = `
<div class="card-content" data-item-id="${item._id}">
<h3 class="item-name-cell">${item.Name}</h3>
<p class="item-ort-cell"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-filter1-cell"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-filter2-cell"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-filter3-cell"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
<p class="item-code-cell"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-count-cell"><strong>Anzahl:</strong> ${groupedCount}</p>
${isGroupedItem ? `<p class="item-count-cell"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
<h3 class="item-col-name">${item.Name}</h3>
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
<div class="image-container">
${imagesHtml}
</div>
@@ -1083,10 +1099,10 @@
}
});
// Populate filter options
populateFilterOptions('filter1-options', [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Populate filter options using server-provided full filter lists when available.
populateFilterOptions('filter1-options', allFilter1Values.size ? [...allFilter1Values].sort() : [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', allFilter2Values.size ? [...allFilter2Values].sort() : [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', allFilter3Values.size ? [...allFilter3Values].sort() : [...filter3Values].sort(), 3);
// Start display from leftmost position on full reload only.
if (!append) {
@@ -1631,7 +1647,7 @@
<div class="detail-group">
<div class="detail-label">Anschaffungskosten:</div>
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
</div>
<div class="detail-group full-width">
@@ -1646,7 +1662,7 @@
<span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span>
</button>
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background: var(--ui-surface-soft);">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div>
</div>
@@ -1719,7 +1735,7 @@
: `<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
<button type="button" class="close-modal secondary-close" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
@@ -2847,7 +2863,7 @@
}
.filter-toggle, .clear-filter {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border: 1px solid #dee2e6;
border-radius: 3px;
padding: 3px 8px;
@@ -3028,7 +3044,7 @@
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
background: var(--ui-surface-soft);
color: var(--ui-text);
justify-content: center;
align-items: center;
@@ -3097,6 +3113,8 @@
scroll-snap-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
@@ -3141,7 +3159,7 @@
.item-modal {
display: none;
position: fixed;
z-index: 1000;
z-index: 2500;
left: 0;
top: 0;
width: 100%;
@@ -3468,6 +3486,19 @@
/* Responsive adjustments */
@media (max-width: 768px) {
/* Mobile viewport and scrolling improvements */
html, body {
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
touch-action: pan-y !important;
height: 100% !important;
}
body {
padding: 0 !important;
margin: 0 !important;
}
.filter-container {
flex-direction: column;
}
@@ -3553,8 +3584,9 @@
display: grid !important;
grid-template-columns: 1fr !important;
gap: 15px !important;
padding: 10px 0 0 !important;
padding: 10px 0 !important;
overflow-x: visible !important;
scroll-snap-type: none !important;
}
.item-card {
@@ -3843,7 +3875,7 @@
max-height: 400px;
overflow: hidden;
border-radius: 8px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
}
/* Borrower and appointment info panels in modal */
@@ -3875,7 +3907,7 @@
.detail-value { color: #212529; flex: 1; word-wrap: break-word; }
.detail-group.full-width .detail-label { margin-bottom: 5px; }
.detail-group.full-width .detail-value {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 10px;
border-radius: 4px;
border: 1px solid #dee2e6;
@@ -4098,6 +4130,7 @@
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
box-sizing: border-box !important;
}
.filter-group {
@@ -4157,7 +4190,7 @@
overflow-wrap: break-word !important;
}
body.table-view .item-card .card-content .item-name-cell {
body.table-view .item-card .card-content .item-col-name {
font-size: 0.95rem !important;
}
@@ -4314,13 +4347,12 @@
}
.modal-content {
width: 100% !important;
max-width: 100% !important;
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 80px auto 10px !important;
max-height: 85vh !important;
overflow-y: auto !important;
border-radius: 8px !important;
padding: 16px !important;
}
@@ -4582,4 +4614,74 @@ function openItemQuick(id){
}
}
</style>
{% endblock %}
{% endblock %}
<style>
/* Admin/Main Content Container align */
.admin-content-container {
--table-columns: minmax(190px, 2fr) repeat(6, minmax(110px, 1fr));
}
@media screen and (min-width: 769px) and (max-width: 1024px) {
.admin-content-container {
width: 90% !important;
}
.items-container {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 20px !important;
}
}
@media screen and (max-width: 768px) {
.admin-content-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
.item-card .item-image {
width: 100% !important;
height: auto !important;
max-height: 300px !important;
display: block !important;
object-fit: cover !important;
}
.item-card .image-container {
height: 200px !important;
overflow: hidden !important;
position: relative !important;
margin-bottom: 15px !important;
}
.actions {
gap: 8px !important;
border-top: none !important;
padding-top: 8px !important;
flex-direction: column !important;
align-items: stretch !important;
}
.actions form,
.actions button,
.actions a {
width: 100% !important;
}
.items-container, .item-card, .image-container, .item-image {
touch-action: pan-y !important;
-webkit-overflow-scrolling: touch !important;
}
}
@media (max-width: 767px) {
body {
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.admin-content-container {
width: 100%;
max-width: 100%;
margin: 0;
padding: 12px 12px 18px;
box-sizing: border-box;
overflow-x: hidden;
}
}
</style>
+16 -15
View File
@@ -104,7 +104,7 @@
border-radius: 8px;
padding: 9px 12px;
cursor: pointer;
background: #f8fafc;
background: var(--ui-surface-soft);
color: var(--ui-title);
font-weight: 600;
text-align: center;
@@ -152,7 +152,7 @@
}
.filter-toggle, .clear-filter {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border: 1px solid #dee2e6;
border-radius: 3px;
padding: 3px 8px;
@@ -333,7 +333,7 @@
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
background: var(--ui-surface-soft);
color: var(--ui-text);
justify-content: center;
align-items: center;
@@ -582,7 +582,7 @@
.item-modal {
display: none;
position: fixed;
z-index: 1000;
z-index: 2500;
left: 0;
top: 0;
width: 100%;
@@ -706,7 +706,7 @@
padding: 15px;
border: 1px solid var(--ui-border);
border-radius: 5px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
}
.filter-inputs h3 {
@@ -1049,7 +1049,7 @@
}
.detail-group.full-width .detail-value {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 10px;
border-radius: 4px;
border: 1px solid #dee2e6;
@@ -1069,7 +1069,7 @@
max-height: 400px;
overflow: hidden;
border-radius: 8px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
}
.modal-image {
@@ -2022,7 +2022,7 @@
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--ui-border);
background: #f8fafc;
background: var(--ui-surface-soft);
}
.calendar-header button {
@@ -2038,7 +2038,7 @@
}
.calendar-header button:hover {
background: #f1f5f9;
background: var(--ui-bg);
}
.calendar-header span {
@@ -2082,7 +2082,7 @@
}
.cal-cell:not(.header):not(.empty):hover {
background: #f1f5f9;
background: var(--ui-bg);
}
.cal-cell.empty {
@@ -2117,7 +2117,7 @@
.calendar-day-details {
border-top: 1px solid #e7edf5;
padding: 12px;
background: #f8fafc;
background: var(--ui-surface-soft);
display: none;
}
@@ -2584,7 +2584,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<input type="date" id="edit-year" name="anschaffungsjahr">
</div>
<div class="form-group">
<label for="edit-cost">Anschaffungskosten:</label>
<label for="edit-cost">Anschaffungskosten (€):</label>
<input id="edit-cost" name="anschaffungskosten">
</div>
<div class="form-group">
@@ -3530,7 +3530,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
const imagesHtml = item.Images ? item.Images.map((image, index) => {
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((image, index) => {
// Check if the image already has a full URL path or just the filename
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
image :
@@ -4441,7 +4441,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="detail-group">
<div class="detail-label">Anschaffungskosten:</div>
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
</div>
<div class="detail-group full-width">
@@ -4456,7 +4456,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span>
</button>
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background: var(--ui-surface-soft);">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div>
</div>
@@ -4535,6 +4535,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
+3 -3
View File
@@ -78,12 +78,12 @@
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
margin-bottom: 20px;
background-color: #fff;
background-color: var(--ui-surface);
border: 1px solid #e9ecef;
}
.card-header {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
}
@@ -170,7 +170,7 @@
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border-radius: 4px;
border-left: 3px solid #6c757d;
}
+4 -4
View File
@@ -61,7 +61,7 @@
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background-color: #333;
background-color: var(--ui-text);
color: white;
padding: 5px 10px;
border-radius: 4px;
@@ -405,7 +405,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
.borrowed-item {
background-color: #fff;
background-color: var(--ui-surface);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
@@ -457,7 +457,7 @@ document.addEventListener('DOMContentLoaded', function() {
.exemplar-details {
margin-top: 10px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 10px;
border-radius: 5px;
border-left: 3px solid #6c757d;
@@ -515,7 +515,7 @@ document.addEventListener('DOMContentLoaded', function() {
.no-items-message {
text-align: center;
padding: 30px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border-radius: 5px;
color: #6c757d;
margin-top: 20px;
+2 -2
View File
@@ -17,7 +17,7 @@
<div id="push-notification-settings" style="margin-bottom: 24px;"></div>
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<section style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
{% if user_notifications %}
<div style="display:grid; gap:10px;">
@@ -53,7 +53,7 @@
</section>
{% if is_admin_user %}
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<section style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
{% if admin_notifications %}
<div style="display:grid; gap:10px;">
+4 -4
View File
@@ -121,7 +121,7 @@
--primary-dark: #2980b9;
--success-color: #2ecc71;
--error-color: #e74c3c;
--text-color: #333;
--text-color: var(--ui-text);
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
@@ -163,7 +163,7 @@ body {
}
.content {
background-color: #fff;
background-color: var(--ui-surface);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
@@ -345,7 +345,7 @@ input::placeholder {
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
background: var(--ui-surface-soft);
}
.password-rules-title {
@@ -406,7 +406,7 @@ input::placeholder {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
background: #f8fafc;
background: var(--ui-surface-soft);
}
.permission-panel h4 {
+1 -1
View File
@@ -129,7 +129,7 @@ function createLoadingIndicator(message) {
}
.reset-loading-message {
color: #333;
color: var(--ui-text);
font-size: 16px;
font-weight: 500;
}
+8 -8
View File
@@ -207,7 +207,7 @@
}
.reset-item-info {
background: #f8f9fa;
background: var(--ui-surface-soft);
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
@@ -216,7 +216,7 @@
.reset-item-info h3 {
margin: 0 0 15px 0;
color: #333;
color: var(--ui-text);
font-size: 1.3rem;
}
@@ -232,7 +232,7 @@
}
.reset-item-details strong {
color: #333;
color: var(--ui-text);
}
.reset-warning-section {
@@ -285,7 +285,7 @@
flex-direction: column;
cursor: pointer;
font-weight: 500;
color: #333;
color: var(--ui-text);
}
.reset-action-desc {
@@ -298,7 +298,7 @@
.active-borrowings {
margin-top: 10px;
padding: 10px;
background: #f8f9fa;
background: var(--ui-surface-soft);
border-radius: 5px;
font-size: 0.9rem;
}
@@ -321,7 +321,7 @@
.reset-options-section h4 {
margin: 0 0 15px 0;
color: #333;
color: var(--ui-text);
}
.reset-option {
@@ -365,7 +365,7 @@
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
color: var(--ui-text);
}
.reset-reason-section textarea {
@@ -391,7 +391,7 @@
display: flex;
justify-content: flex-end;
gap: 15px;
background: #f8f9fa;
background: var(--ui-surface-soft);
border-radius: 0 0 12px 12px;
}
@@ -45,7 +45,7 @@
}
h1 {
color: #333;
color: var(--ui-text);
font-size: 28px;
margin-bottom: 10px;
}
@@ -67,7 +67,7 @@
}
.info-box strong {
color: #333;
color: var(--ui-text);
}
.info-box p {
@@ -106,7 +106,7 @@
.btn-secondary {
background: #e0e0e0;
color: #333;
color: var(--ui-text);
}
.btn-secondary:hover {
+2 -2
View File
@@ -22,7 +22,7 @@
}
.student-card-form {
background: #f5f5f5;
background: var(--ui-bg);
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
@@ -43,7 +43,7 @@
.form-group label {
font-weight: 600;
margin-bottom: 5px;
color: #333;
color: var(--ui-text);
}
.form-group input,
+1 -1
View File
@@ -1601,7 +1601,7 @@ document.addEventListener('DOMContentLoaded', function() {
.booking-summary {
margin: 15px 0;
padding: 10px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9em;
+69 -6
View File
@@ -72,7 +72,7 @@
.tutorial-side {
grid-column: span 12;
background: #ffffff;
background: var(--ui-surface);
border: 1px solid #cfdbe8;
border-radius: 16px;
padding: 16px;
@@ -102,7 +102,7 @@
.workflow-nav button {
border: 1px solid #dbe3ee;
background: #f8fafc;
background: var(--ui-surface-soft);
color: #0f172a;
border-radius: 12px;
padding: 11px 12px;
@@ -133,7 +133,7 @@
.workflow-step {
grid-column: span 12;
background: #ffffff;
background: var(--ui-surface);
border: 1px solid #cfdbe8;
border-radius: 16px;
padding: 18px;
@@ -189,6 +189,38 @@
font-size: 1rem;
}
.tutorial-tooltip-toggle {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 20px;
padding: 16px;
border-radius: 18px;
background: rgba(224, 242, 254, 0.9);
border: 1px solid #bae6fd;
}
.tutorial-tooltip-toggle label {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
color: #0f172a;
}
.tutorial-tooltip-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: #2563eb;
}
.tooltip-description {
margin: 0;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
.workflow-controls {
display: flex;
gap: 8px;
@@ -230,6 +262,13 @@
<span class="tutorial-pill">Format: Ruhig und geführt</span>
<span class="tutorial-pill">Benutzer: {{ username }}</span>
</div>
<div class="tutorial-tooltip-toggle">
<label for="toggleTutorialTooltips">
<input type="checkbox" id="toggleTutorialTooltips" />
Tutorial-Tooltips für das gesamte System aktivieren
</label>
<p class="tooltip-description">Wenn aktiviert, erscheinen auf allen Seiten erklärende Hinweise und Icons für die wichtigsten Navigationselemente.</p>
</div>
</section>
<div class="tutorial-grid">
@@ -331,17 +370,17 @@
<h3>Tägliche Aufgaben</h3>
<p>Das ist der gewöhnliche Arbeitsablauf während eines Schultages.</p>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Morgens:</p>
<p style="margin: 0; color: #334155;">Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.</p>
</div>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Mittags:</p>
<p style="margin: 0; color: #334155;">Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.</p>
</div>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Nachmittags:</p>
<p style="margin: 0; color: #334155;">Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.</p>
</div>
@@ -438,8 +477,32 @@
renderStep();
});
const tooltipToggle = document.getElementById('toggleTutorialTooltips');
const tooltipModeKey = 'inventarsystem_tutorial_tooltips_enabled_{{ username }}';
function setTooltipMode(enabled) {
localStorage.setItem(tooltipModeKey, enabled ? '1' : '0');
document.body.classList.toggle('tutorial-tooltips-active', enabled);
}
function loadTooltipMode() {
const saved = localStorage.getItem(tooltipModeKey);
const enabled = saved === '1';
if (tooltipToggle) {
tooltipToggle.checked = enabled;
}
setTooltipMode(enabled);
}
if (tooltipToggle) {
tooltipToggle.addEventListener('change', function () {
setTooltipMode(this.checked);
});
}
loadState();
renderStep();
loadTooltipMode();
})();
</script>
{% endblock %}
+13 -13
View File
@@ -78,14 +78,14 @@
.book-info {
padding: 15px;
background-color: #fff;
background-color: var(--ui-surface);
border: 1px solid #ddd;
border-radius: 5px;
margin-bottom: 10px;
}
.book-info h4 {
color: #333;
color: var(--ui-text);
margin: 0 0 10px 0;
font-size: 1.2em;
font-weight: bold;
@@ -115,7 +115,7 @@
}
.book-description h5 {
color: #333;
color: var(--ui-text);
margin: 0 0 8px 0;
font-size: 1em;
font-weight: bold;
@@ -125,7 +125,7 @@
max-height: 150px;
overflow-y: auto;
padding: 5px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border: 1px solid #e9ecef;
border-radius: 3px;
font-size: 0.9em;
@@ -187,7 +187,7 @@
font-style: italic;
text-align: center;
padding: 20px;
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
border: 1px solid #dee2e6;
border-radius: 4px;
}
@@ -334,7 +334,7 @@
}
.popup-content p {
color: #333;
color: var(--ui-text);
margin: 15px 0;
line-height: 1.5;
font-size: 1em;
@@ -380,7 +380,7 @@
}
.popup-close-x:hover {
color: #333;
color: var(--ui-text);
}
/* Upload form styles */
@@ -388,13 +388,13 @@
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
background-color: var(--ui-surface);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.upload-form h1 {
color: #333;
color: var(--ui-text);
margin-bottom: 30px;
text-align: center;
font-size: 2em;
@@ -408,7 +408,7 @@
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
color: var(--ui-text);
}
.form-group input,
@@ -428,14 +428,14 @@
}
.filter-inputs {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.filter-inputs h3 {
color: #333;
color: var(--ui-text);
margin-bottom: 15px;
font-size: 1.2em;
}
@@ -863,7 +863,7 @@
<input type="date" id="anschaffungsjahr" name="anschaffungsjahr">
</div>
<div class="form-group">
<label for="anschaffungskosten">Anschaffungskosten</label>
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten">
</div>
<div class="form-group">
+3 -3
View File
@@ -407,14 +407,14 @@
}
.filter-bar {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 15px;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.password-requirements {
background-color: #f8f9fa;
background-color: var(--ui-surface-soft);
padding: 10px;
border-radius: 4px;
margin-top: 10px;
@@ -439,7 +439,7 @@
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 12px;
background: #f8fafc;
background: var(--ui-surface-soft);
}
.permissions-group h6 {
+187 -12
View File
@@ -10,28 +10,166 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
from flask import request, g, has_request_context
from functools import wraps
import logging
import os
import re
import settings as cfg
from settings import MongoClient
logger = logging.getLogger(__name__)
# Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {}
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
def _get_nested_value(source, path, default=None):
current = source
for key in path:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
return current
def _parse_tenant_db_map():
mapping = {}
raw_map = os.getenv('INVENTAR_TENANT_DB_MAP', '').strip()
if not raw_map:
return mapping
for mapping_pair in re.split(r'[;,\s]+', raw_map):
if '=' not in mapping_pair:
continue
key, value = mapping_pair.split('=', 1)
mapping[key.strip()] = value.strip()
return mapping
def _parse_port_from_host(host):
"""Parse host header and return (hostname, port) when a numeric port is present."""
if host.startswith('['):
# IPv6 with port: [::1]:10000
if ']:' in host:
host_part, _, port_part = host.rpartition(']:')
return host_part + ']', port_part
return host, None
if host.count(':') == 1:
hostname, port = host.split(':', 1)
if port.isdigit():
return hostname, port
return host, None
def _tenant_id_for_port(port):
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
for tenant_id, config in TENANT_REGISTRY.items():
if isinstance(config, dict) and config.get('port') is not None:
try:
configured_port = str(int(config.get('port')))
except (TypeError, ValueError):
continue
if configured_port == str(port):
return tenant_id
port_map = os.getenv('INVENTAR_TENANT_PORT_MAP', '').strip()
if port_map:
for mapping in re.split(r'[;,\s]+', port_map):
if '=' not in mapping:
continue
key, value = mapping.split('=', 1)
if key.strip() == str(port):
return value.strip()
return None
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
if tenant_id is None:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {}
return TENANT_REGISTRY.get('default', {}) or {}
def _normalize_db_name(db_name):
if not db_name:
return None
db_name = str(db_name).strip()
if db_name and not db_name.startswith('inventar_'):
db_name = f'inventar_{db_name}'
return db_name
def _tenant_db_aliases(tenant_id):
aliases = []
env_map = _parse_tenant_db_map()
if tenant_id in env_map:
aliases.append(env_map[tenant_id])
if tenant_id.lower().startswith('schule'):
aliases.append(tenant_id.lower().replace('schule', 'school', 1))
elif tenant_id.lower().startswith('school'):
aliases.append(tenant_id.lower().replace('school', 'schule', 1))
return [alias for alias in aliases if alias]
def _resolve_db_alias(tenant_id, db_name):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
available_databases = client.list_database_names()
if db_name in available_databases:
return db_name
for alias in _tenant_db_aliases(tenant_id):
candidate = _normalize_db_name(alias)
if candidate in available_databases:
logger.warning(
"Tenant DB fallback applied for tenant %r: %r -> %r",
tenant_id,
db_name,
candidate,
)
return candidate
except Exception as exc:
logger.exception("Tenant DB alias resolution failed for %r: %s", tenant_id, exc)
return db_name
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id)
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
return bool(enabled)
class TenantContext:
"""
Manages current tenant context for request lifecycle.
Automatically resolves tenant from subdomain or request header.
Automatically resolves tenant from port, header, or subdomain.
"""
def __init__(self):
self.tenant_id = None
self.db_name = None
self.subdomain = None
self.port = None
self.config = {}
def resolve_tenant(self):
"""
Resolve tenant from request context.
Priority: Header > Subdomain > Default
Priority: Header > Port mapping > Subdomain > Default
"""
if not has_request_context():
return None
@@ -40,10 +178,26 @@ class TenantContext:
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header:
self.tenant_id = tenant_from_header
self.config = get_tenant_config(tenant_from_header)
return self._get_db_name(tenant_from_header)
# Priority 2: Subdomain extraction
# Priority 2: Port-based tenant mapping
host = request.host.lower()
_, port = _parse_port_from_host(host)
self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port:
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction
parts = host.split('.')
# Extract subdomain from host
@@ -56,20 +210,41 @@ class TenantContext:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}"
)
return self._get_db_name(potential_subdomain)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Fallback to default tenant if no subdomain detected
self.tenant_id = 'default'
return self._get_db_name('default')
# Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
if 'default' in TENANT_REGISTRY:
self.tenant_id = 'default'
self.config = get_tenant_config('default')
return self._get_db_name('default')
self.tenant_id = None
self.config = {}
self.db_name = cfg.MONGODB_DB
return self.db_name
def _get_db_name(self, tenant_id):
"""
Get MongoDB database name for tenant.
Format: inventar_<tenant_id>
Format: inventar_<tenant_id> unless tenant config overrides it.
"""
# Sanitize tenant_id for MongoDB database name
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in tenant_id.lower())
db_name = f"inventar_{sanitized}"
explicit_db = None
if isinstance(self.config, dict):
explicit_db = self.config.get('db') or self.config.get('db_name')
if explicit_db:
db_name = _normalize_db_name(explicit_db)
else:
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in tenant_id.lower())
db_name = f"inventar_{sanitized}"
db_name = _resolve_db_alias(tenant_id, db_name)
self.db_name = db_name
return db_name
@@ -125,8 +300,8 @@ def get_tenant_db(mongo_client):
ctx = get_tenant_context()
if ctx:
return ctx.get_database(mongo_client)
# Fallback to default database
return mongo_client['inventar_default']
# Fallback to configured default database
return mongo_client[cfg.MONGODB_DB]
def register_tenant(tenant_id, config=None):
+159 -64
View File
@@ -12,11 +12,18 @@ Provides methods for creating, validating, and retrieving user information.
'''
import hashlib
import copy
import logging
import re
import secrets
import string
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
logger = logging.getLogger('app')
logger.setLevel(logging.DEBUG)
logger.propagate = True
def normalize_student_card_id(card_id):
"""Normalize student card IDs for reliable lookup."""
@@ -43,24 +50,33 @@ def _clean_name_fragment(value):
return cleaned
def _get_tenant_db(client):
"""Return the current tenant database for the request, or fall back to default."""
try:
from tenant import get_tenant_db
return get_tenant_db(client)
except Exception:
return client[cfg.MONGODB_DB]
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias like 'SimFri'."""
"""Build a deterministic, non-personalized short alias from 2 letters each."""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
if first and last:
return (first[:3] + last[:3]).title()
return (first[:2] + last[:2]).title()
combined = (first + last)
if not combined:
return 'User'
return combined[:6].title()
return combined[:4].title()
def build_username_from_name(first_name, last_name=''):
"""
Build a deterministic username abbreviation from first and last name.
Uses the same short alias logic (e.g. SimFri) and stores it lowercase.
Uses 2 letters from each name and stores it lowercase.
Args:
first_name (str): First name
@@ -75,32 +91,23 @@ def build_username_from_name(first_name, last_name=''):
def build_unique_username_from_name(first_name, last_name=''):
"""
Build a unique username based on the abbreviation logic.
If a collision occurs, increase the username by one additional letter
from the combined cleaned name until it is unique.
Build a unique username from the first 2 letters of the first name and
the first 2 letters of the last name.
"""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
combined = (first + last).lower()
base_username = (first[:2] + last[:2]).lower()
base_username = build_username_from_name(first_name, last_name)
if not combined:
combined = base_username or 'user'
if not base_username:
base_username = 'user'
start_len = len(base_username) if base_username else min(6, len(combined))
start_len = max(1, min(start_len, len(combined)))
if not get_user(base_username):
return base_username
# Main strategy: take one more letter on each collision.
for length in range(start_len, len(combined) + 1):
candidate = combined[:length]
if not get_user(candidate):
return candidate
# Fallback if full combined name is already taken repeatedly.
suffix = 2
while get_user(f"{combined}{suffix}"):
while get_user(f"{base_username}{suffix}"):
suffix += 1
return f"{combined}{suffix}"
return f"{base_username}{suffix}"
ACTION_PERMISSION_KEYS = (
@@ -310,7 +317,7 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
}
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': username}, {'$set': update_data})
@@ -325,7 +332,7 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
def get_favorites(username):
"""Return a list of favorite item ObjectId strings for the user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
@@ -339,7 +346,7 @@ def add_favorite(username, item_id):
"""Add an item to user's favorites (idempotent)."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
@@ -354,7 +361,7 @@ def remove_favorite(username, item_id):
"""Remove an item from user's favorites."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
@@ -417,13 +424,81 @@ def check_nm_pwd(username, password):
Returns:
dict: User document if credentials are valid, None otherwise
"""
db_name = cfg.MONGODB_DB
tenant_db = None
ctx = None
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
tenant_db = ctx.db_name or ctx.resolve_tenant()
db_name = tenant_db
except Exception as exc:
logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}")
logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username,
ctx.tenant_id if ctx else None,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
getattr(cfg, 'MONGODB_URI', None),
)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
hashed_password = hashlib.sha512(password.encode()).hexdigest()
user = users.find_one({'Username': username, 'Password': hashed_password})
client.close()
return user
try:
hashed_password = hashing(password)
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
available_dbs = []
try:
available_dbs = client.list_database_names()
logger.debug("MongoDB connected. Available databases=%s", available_dbs)
except Exception as exc:
logger.exception("Unable to list MongoDB databases: %s", exc)
db = client[db_name]
try:
existing_collections = db.list_collection_names()
except Exception as exc:
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
existing_collections = []
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
users = db['users']
query = {'$or': [{'Username': username}, {'username': username}]}
logger.debug("Running user lookup on %r: %s", db_name, query)
user_record = users.find_one(query)
if user_record is None:
logger.warning("No user document found in db=%r for username=%r", db_name, username)
if db_name not in available_dbs:
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
if 'users' not in existing_collections:
logger.warning("Tenant database %r has no users collection", db_name)
return None
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
stored_password = user_record.get('Password') or user_record.get('password')
if stored_password is None:
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
return None
if stored_password != hashed_password:
logger.warning(
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
username,
db_name,
hashed_password,
stored_password,
)
return None
return user_record
finally:
client.close()
return None
def add_user(
@@ -449,7 +524,7 @@ def add_user(
bool: True if user was added successfully, False if password was too weak
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
if not check_password_strength(password):
return False
@@ -471,7 +546,7 @@ def add_user(
'Admin': False,
'active_ausleihung': None,
'name': name_alias,
'last_name': '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
@@ -499,7 +574,7 @@ def student_card_exists(student_card_id):
if not normalized:
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
exists = users.find_one({'StudentCardId': normalized}) is not None
client.close()
@@ -512,7 +587,7 @@ def get_user_by_student_card(student_card_id):
if not normalized:
return None
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
found_user = users.find_one({'StudentCardId': normalized})
client.close()
@@ -530,11 +605,13 @@ def make_admin(username):
bool: True if user was promoted successfully
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': True}})
result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
client.close()
return True
return result.matched_count > 0
def remove_admin(username):
"""
@@ -547,11 +624,13 @@ def remove_admin(username):
bool: True if user was demoted successfully
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': False}})
result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
client.close()
return True
return result.matched_count > 0
def get_user(username):
"""
@@ -564,11 +643,31 @@ def get_user(username):
dict: User document or None if not found
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
users_return = users.find_one({'Username': username})
client.close()
return users_return
try:
def find_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username}) or users.find_one({'username': username})
# Try current tenant first when available
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
user = find_in_db(ctx.db_name)
if user:
return user
except Exception:
pass
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
if user:
return user
return None
finally:
client.close()
def check_admin(username):
@@ -581,12 +680,8 @@ def check_admin(username):
Returns:
bool: True if user is an administrator, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
user = users.find_one({'Username': username})
client.close()
return user and user.get('Admin', False)
user = get_user(username)
return bool(user and user.get('Admin', False))
def update_active_ausleihung(username, id_item, ausleihung):
@@ -602,7 +697,7 @@ def update_active_ausleihung(username, id_item, ausleihung):
bool: True if successful
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}})
client.close()
@@ -620,7 +715,7 @@ def get_active_ausleihung(username):
dict: Active borrowing information or None
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
return user['active_ausleihung']
@@ -638,7 +733,7 @@ def has_active_borrowing(username):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'username': username})
@@ -669,14 +764,14 @@ def delete_user(username):
bool: True if user was deleted successfully, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'username': username})
client.close()
if result.deleted_count == 0:
# Try with different field name
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'Username': username})
client.close()
@@ -698,7 +793,7 @@ def update_active_borrowing(username, item_id, status):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one(
{'username': username},
@@ -731,7 +826,7 @@ def get_name(username):
str: String of name
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("name")
@@ -746,7 +841,7 @@ def get_last_name(username):
str: String of last_name
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("last_name")
@@ -763,7 +858,7 @@ def get_all_users():
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
all_users = list(users.find())
client.close()
@@ -787,7 +882,7 @@ def update_password(username, new_password):
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
# Hash the new password
@@ -820,7 +915,7 @@ def update_user_name(username, name, last_name):
try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one(
+1 -2
View File
@@ -4,9 +4,8 @@
import json
import sys
from pymongo import MongoClient
import settings as cfg
from settings import MongoClient
import audit_log as al
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Monthly log archival script for Inventarsystem
# Runs on first day of month during automatic update
# Compresses logs to tar.gz, stores for up to 1 year, removes uncompressed originals
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$PROJECT_DIR/logs"
ARCHIVE_BASE_DIR="$PROJECT_DIR/backups/logs_archive"
LOG_ARCHIVE_FILE="$LOG_DIR/archive-logs.log"
GUARD_FILE="$PROJECT_DIR/.last-log-archive"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
# Ensure archive base directory exists
mkdir -p "$ARCHIVE_BASE_DIR"
chmod 755 "$ARCHIVE_BASE_DIR" 2>/dev/null || true
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_ARCHIVE_FILE"
}
should_archive_logs() {
local today month_day guard_date
today="$(date +%Y-%m-%d)"
month_day="$(date +%d)"
# Only run on first day of month
if [ "$month_day" != "01" ]; then
return 1
fi
# Check if we already ran today
if [ -f "$GUARD_FILE" ]; then
guard_date="$(cat "$GUARD_FILE" 2>/dev/null || echo '')"
if [ "$guard_date" = "$today" ]; then
log_message "Log archive already executed today ($today). Skipping."
return 1
fi
fi
return 0
}
archive_application_logs() {
local archive_name archive_path retention_days cleanup_count deleted_count
if [ ! -d "$LOG_DIR" ] || [ -z "$(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' 2>/dev/null)" ]; then
log_message "No log files found in $LOG_DIR. Nothing to archive."
return 0
fi
archive_name="inventar_logs_$(date +%Y-%m-01_%H%M%S)"
archive_path="$ARCHIVE_BASE_DIR/${archive_name}.tar.gz"
log_message "Starting monthly log archival..."
log_message "Archive destination: $archive_path"
# Create temporary directory for prepping files
local tmp_prep_dir
tmp_prep_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_prep_dir:-}"' RETURN
# Copy log files to temp directory (excluding archive log itself during copy)
if ! find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' ! -name 'archive-logs.log' -exec cp {} "$tmp_prep_dir/" \; 2>/dev/null; then
log_message "WARNING: Could not copy all log files to temp directory"
fi
# Create tar.gz archive
if tar -czf "$archive_path" -C "$tmp_prep_dir" . 2>/dev/null; then
log_message "Log archive created successfully: $archive_path"
log_message "Archive size: $(du -h "$archive_path" | cut -f1)"
else
log_message "ERROR: Failed to create log archive"
return 1
fi
# Remove uncompressed log files (keep archive-logs.log for continuity)
log_message "Removing uncompressed log files..."
local files_removed=0
while IFS= read -r log_file; do
if [ "$log_file" != "$LOG_ARCHIVE_FILE" ]; then
rm -f "$log_file" && ((++files_removed)) || true
fi
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log')
log_message "Removed $files_removed uncompressed log file(s)"
trap - RETURN
}
cleanup_old_archives() {
local retention_days cleanup_count deleted_count total_archives
retention_days="365"
# Find and remove archives older than 1 year
log_message "Cleaning up archives older than $retention_days days..."
total_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)"
deleted_count="0"
find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' -mtime +$retention_days | while read -r old_archive; do
log_message "Removing old archive: $old_archive"
rm -f "$old_archive"
((++deleted_count)) || true
done 2>/dev/null || true
# Count deleted (safer approach)
local current_archives
current_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)"
deleted_count="$((total_archives - current_archives))"
if [ "$deleted_count" -gt 0 ]; then
log_message "Deleted $deleted_count old archive(s); kept $current_archives"
else
log_message "No old archives to delete (total: $total_archives)"
fi
}
main() {
if ! should_archive_logs; then
return 0
fi
log_message "=== Monthly log archival started ==="
archive_application_logs || {
log_message "ERROR: Log archival failed"
return 1
}
cleanup_old_archives || {
log_message "WARNING: Archive cleanup encountered issues"
}
# Update guard file with today's date
date +%Y-%m-%d > "$GUARD_FILE"
log_message "=== Monthly log archival completed successfully ==="
return 0
}
main "$@"
Executable → Regular
+115 -483
View File
@@ -1,529 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
# ========================================================
# Inventarsystem backup helper
# - Creates a dated backup folder/archive under /var/backups (by default)
# - Exports MongoDB via run-backup.sh into mongodb_backup/
# - Cleans up backups older than KEEP_DAYS
#
# Options:
# --dest|--out|--backup-dir <dir> Destination base directory (default: /var/backups)
# --db|--db-name <name> MongoDB database name (default: Inventarsystem)
# --uri|--mongo-uri <uri> MongoDB URI (default: mongodb://localhost:27017/)
# --invoice-archive-dir <dir> Invoice archive directory (default: <dest>/invoice-archive)
# --invoice-keep-days <N> Retention for invoice archive in days (default: 3650)
# --log <file> Log file (default: ./logs/backup.log)
# --no-compress Disable compression (keeps directory instead of .tar.gz)
# --compress-level <0-9> Compression level flag (only 0 is special here)
# --keep-days <N> Age-based retention in days (default: 7; 0 disables age filter)
# --min-keep <N> Always keep at least N most recent backups (default: 7)
# --mode <auto|host|docker> Backup mode (default: auto)
# -h|--help Show help
# ========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR}"
# Default destination now /var/backups; override with --dest or BACKUP_BASE_DIR env
BACKUP_BASE_DIR="${BACKUP_BASE_DIR:-/var/backups}"
LOG_DIR="$PROJECT_DIR/logs"
# Create only the log directory here; backup dir is created later with sudo if needed
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}"
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false
COMPOSE_FILE="docker-compose-multitenant.yml"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-/var/backups/invoice-archive}"
KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
MODE="auto"
BASE_NAME=""
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Usage: $0 [options]
Options:
--dest|--out|--backup-dir <dir> Destination base directory (default: $BACKUP_BASE_DIR)
--db|--db-name <name> MongoDB database name (default: $DB_NAME)
--uri|--mongo-uri <uri> MongoDB URI (default: $MONGO_URI)
--invoice-archive-dir <dir> Invoice archive directory (default: $INVOICE_ARCHIVE_DIR)
--invoice-keep-days <N> Retention for invoice archive in days (default: $INVOICE_KEEP_DAYS)
--log <file> Log file (default: $LOG_FILE)
--no-compress Disable compression (keeps directory instead of .tar.gz)
--compress-level <0-9> Compression level flag (only 0 is special here)
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
-h|--help Show this help and exit
--invoice-archive-dir DIR Directory for invoice archive files
(default: $INVOICE_ARCHIVE_DIR)
--invoice-keep-days N Remove archived invoice files older than N days
(default: $KEEP_DAYS)
--mode auto|docker|host Backup mode (default: auto)
--base-name NAME Base filename prefix for archive files
-h, --help Show this help message
EOF
}
# Parse CLI arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--dest|--out|--backup-dir)
BACKUP_BASE_DIR="$2"; shift 2;;
--db|--db-name)
DB_NAME="$2"; shift 2;;
--uri|--mongo-uri)
MONGO_URI="$2"; shift 2;;
--invoice-archive-dir)
INVOICE_ARCHIVE_DIR="$2"; shift 2;;
--invoice-keep-days)
INVOICE_KEEP_DAYS="$2"; shift 2;;
--log)
LOG_FILE="$2"; shift 2;;
--no-compress)
COMPRESSION_LEVEL=0; shift;;
--compress-level)
COMPRESSION_LEVEL="${2:-6}"; shift 2;;
--keep-days)
KEEP_DAYS="$2"; shift 2;;
--min-keep)
MIN_KEEP="$2"; shift 2;;
--mode)
BACKUP_MODE="$2"; shift 2;;
-h|--help)
usage; exit 0;;
*)
echo "Unknown option: $1" >&2; usage; exit 2;;
esac
done
# Function to log messages
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--invoice-archive-dir)
INVOICE_ARCHIVE_DIR="$2"
shift 2
;;
--invoice-keep-days)
KEEP_DAYS="$2"
shift 2
;;
--mode)
MODE="$2"
shift 2
;;
--base-name)
BASE_NAME="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'" >&2
usage
exit 2
;;
esac
done
}
init_docker_compose() {
if docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
return 0
fi
if sudo docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
return 0
fi
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
return 1
ensure_directory() {
mkdir -p "$INVOICE_ARCHIVE_DIR"
}
compose_cmd() {
if [ "$DOCKER_AVAILABLE" -ne 1 ]; then
cleanup_old_archives() {
if [[ -n "$KEEP_DAYS" ]]; then
find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) -mtime +"$KEEP_DAYS" -print -delete || true
fi
}
host_backup() {
if ! command -v python3 >/dev/null 2>&1; then
return 1
fi
"${DOCKER_COMPOSE_CMD[@]}" "$@"
}
try_backup_db_docker() {
local out_dir="$1"
local stamp
stamp="$(date +"%Y-%m-%d_%H-%M-%S")"
local archive_path="$out_dir/mongodb-${stamp}.archive.gz"
[ "$DOCKER_AVAILABLE" -eq 1 ] || return 1
log_message "Attempting Docker-based MongoDB backup..."
compose_cmd up -d mongodb >/dev/null 2>&1 || return 1
if ! compose_cmd ps --status running mongodb | grep -q mongodb; then
if ! python3 -c 'import pymongo' >/dev/null 2>&1; then
return 1
fi
if compose_cmd exec -T mongodb sh -c "mongodump --archive --gzip --db '$DB_NAME'" > "$archive_path"; then
log_message "Docker DB backup successful: $archive_path"
return 0
fi
return 1
python3 "$SCRIPT_DIR/Web/backup_invoices.py" \
--archive-dir "$INVOICE_ARCHIVE_DIR" \
--base-name "$BASE_NAME"
}
try_backup_invoices_docker() {
local archive_base="$1"
[ "$DOCKER_AVAILABLE" -eq 1 ] || return 1
local tmp_base
tmp_base="/tmp/$(basename "$archive_base")"
log_message "Attempting Docker-based invoice archive export..."
compose_cmd up -d mongodb app >/dev/null 2>&1 || return 1
if ! compose_cmd ps --status running app | grep -q app; then
docker_backup() {
if ! command -v docker >/dev/null 2>&1; then
return 1
fi
if ! docker compose -f "$COMPOSE_FILE" ps -q app >/dev/null 2>&1; then
return 1
fi
local app_cid
app_cid="$(compose_cmd ps -q app)"
[ -n "$app_cid" ] || return 1
if ! sudo docker cp "$PROJECT_DIR/Backup-Invoices.py" "$app_cid:/tmp/Backup-Invoices.py" >/dev/null 2>&1; then
local app_container
app_container="$(docker compose -f "$COMPOSE_FILE" ps -q app | tr -d '[:space:]')"
if [[ -z "$app_container" ]]; then
return 1
fi
if ! compose_cmd exec -T app python /tmp/Backup-Invoices.py \
--uri "mongodb://mongodb:27017/" \
--db "$DB_NAME" \
--out "$tmp_base" >/dev/null 2>&1; then
return 1
fi
sudo docker cp "$app_cid:${tmp_base}.jsonl" "${archive_base}.jsonl" >/dev/null 2>&1 || return 1
sudo docker cp "$app_cid:${tmp_base}.csv" "${archive_base}.csv" >/dev/null 2>&1 || return 1
sudo docker cp "$app_cid:${tmp_base}.meta.json" "${archive_base}.meta.json" >/dev/null 2>&1 || return 1
compose_cmd exec -T app sh -c "rm -f /tmp/Backup-Invoices.py ${tmp_base}.jsonl ${tmp_base}.csv ${tmp_base}.meta.json" >/dev/null 2>&1 || true
return 0
}
backup_invoices_archive() {
log_message "Starting invoice archive export..."
# Ensure archive dir exists and is writable
if [ ! -d "$INVOICE_ARCHIVE_DIR" ]; then
sudo mkdir -p "$INVOICE_ARCHIVE_DIR"
sudo chmod 755 "$INVOICE_ARCHIVE_DIR"
fi
local timestamp
timestamp="$(date +"%Y-%m-%d_%H-%M-%S")"
local archive_base
archive_base="$INVOICE_ARCHIVE_DIR/invoices-$timestamp"
timestamp="$(date +'%Y-%m-%d_%H-%M-%S')"
local output_dir
local remote_base
local py_exec
if [ -x "$PROJECT_DIR/.venv/bin/python" ]; then
py_exec="$PROJECT_DIR/.venv/bin/python"
else
py_exec="python3"
fi
output_dir="/tmp/invoice_archive_${timestamp}"
remote_base="${output_dir}/${BASE_NAME}"
local host_invoice_ok=0
local prefer_docker_invoice=0
if [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then
prefer_docker_invoice=1
log_message "Auto mode: preferring Docker invoice archive export"
fi
docker compose -f "$COMPOSE_FILE" exec -T app mkdir -p "$output_dir"
docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/Web/backup_invoices.py \
--archive-dir "$output_dir" \
--base-name "$BASE_NAME"
if [ "$BACKUP_MODE" != "docker" ] && [ "$prefer_docker_invoice" -ne 1 ]; then
if [ "${USE_NULL_OUTPUT:-false}" = true ]; then
if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" > /dev/null 2>&1; then
host_invoice_ok=1
fi
else
if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1; then
host_invoice_ok=1
fi
fi
fi
mkdir -p "$INVOICE_ARCHIVE_DIR"
docker cp "$app_container":"${remote_base}.jsonl" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.csv" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.meta.json" "$INVOICE_ARCHIVE_DIR/"
if [ "$host_invoice_ok" -ne 1 ]; then
if [ "$BACKUP_MODE" = "host" ]; then
log_message "ERROR: Failed to export invoice archive in host mode"
return 1
fi
if ! try_backup_invoices_docker "$archive_base"; then
log_message "ERROR: Failed to export invoice archive"
return 1
fi
fi
sudo chmod 640 "$archive_base".jsonl "$archive_base".csv "$archive_base".meta.json 2>/dev/null || true
log_message "Invoice archive written: $archive_base.(jsonl|csv|meta.json)"
# Retention cleanup for invoice archive (default: 10 years)
if [ "$INVOICE_KEEP_DAYS" -gt 0 ]; then
local deleted_count=0
while IFS= read -r old_file; do
[ -z "$old_file" ] && continue
if sudo rm -f "$old_file"; then
deleted_count=$((deleted_count + 1))
fi
done < <(find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \
\( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) \
-mtime +"$INVOICE_KEEP_DAYS" -print 2>/dev/null)
log_message "Invoice archive cleanup complete (retention: $INVOICE_KEEP_DAYS days, deleted files: $deleted_count)"
fi
return 0
docker compose -f "$COMPOSE_FILE" exec -T app rm -rf "$output_dir"
}
# Function to create backup
create_backup() {
log_message "Starting backup process..."
# Create date-formatted directory name
CURRENT_DATE=$(date +"%Y-%m-%d")
BACKUP_NAME="Inventarsystem-$CURRENT_DATE"
BACKUP_DIR="$BACKUP_BASE_DIR/$BACKUP_NAME"
BACKUP_ARCHIVE="$BACKUP_BASE_DIR/$BACKUP_NAME.tar.gz"
# Create backup directory if it doesn't exist
if [ ! -d "$BACKUP_BASE_DIR" ]; then
sudo mkdir -p "$BACKUP_BASE_DIR"
sudo chmod 755 "$BACKUP_BASE_DIR"
fi
# Remove existing backup with same date if it exists
if [ -d "$BACKUP_DIR" ]; then
log_message "Removing existing backup directory at $BACKUP_DIR"
sudo rm -rf "$BACKUP_DIR"
fi
if [ -f "$BACKUP_ARCHIVE" ]; then
log_message "Removing existing backup archive at $BACKUP_ARCHIVE"
sudo rm -f "$BACKUP_ARCHIVE"
fi
# Create a temporary directory for backup
log_message "Creating backup at $BACKUP_DIR"
sudo mkdir -p "$BACKUP_DIR"
# Copy project files excluding the backups directory itself (and optionally the venv)
if command -v rsync >/dev/null 2>&1; then
sudo rsync -a \
--exclude='backups' \
--exclude='.venv' \
"$PROJECT_DIR"/ "$BACKUP_DIR"/
else
# Fallback to cp while skipping the backups directory
for entry in "$PROJECT_DIR"/*; do
name=$(basename "$entry")
[[ "$name" == "backups" || "$name" == ".venv" ]] && continue
sudo cp -r "$entry" "$BACKUP_DIR/"
done
fi
# Create database backup
log_message "Running database backup..."
# Create mongodb_backup directory with appropriate permissions first
sudo mkdir -p "$BACKUP_DIR/mongodb_backup"
sudo chmod 755 "$BACKUP_DIR/mongodb_backup"
# Run database backup with our helper script
log_message "Executing database backup script..."
db_backup_ok=0
use_host_db_backup=1
if [ "$BACKUP_MODE" = "docker" ]; then
use_host_db_backup=0
elif [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then
use_host_db_backup=0
log_message "Auto mode: preferring Docker DB backup"
main() {
if [[ -z "$BASE_NAME" ]]; then
BASE_NAME="invoices-$(date +'%Y-%m-%d_%H-%M-%S')"
fi
if [ "$use_host_db_backup" -eq 1 ]; then
# Create Backup_db.log and ensure it's writable only when host mode is used
touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null
chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || {
log_message "WARNING: Failed to set permissions on Backup_db.log. Trying with sudo..."
sudo touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null
sudo chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || {
log_message "WARNING: Failed to create writable Backup_db.log. Redirecting output to /dev/null instead."
USE_NULL_OUTPUT=true
}
}
ensure_directory
# Install pymongo only for host backups
log_message "Checking pymongo installation..."
if ! python3 -c "import pymongo" &>/dev/null; then
log_message "Installing pymongo..."
if [ -f "$PROJECT_DIR/.venv/bin/pip" ]; then
if [ "$USE_NULL_OUTPUT" = true ]; then
"$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 > /dev/null 2>&1 || true
else
"$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true
fi
case "$MODE" in
auto)
if docker_backup; then
cleanup_old_archives
return 0
fi
if ! python3 -c "import pymongo" &>/dev/null; then
if [ "$USE_NULL_OUTPUT" = true ]; then
pip3 install pymongo==4.6.3 > /dev/null 2>&1 || true
else
pip3 install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true
fi
if host_backup; then
cleanup_old_archives
return 0
fi
fi
if [ "${USE_NULL_OUTPUT:-false}" = true ]; then
sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" > /dev/null 2>&1 && db_backup_ok=1 || {
log_message "ERROR: Failed to backup database with original path"
# Try an alternative approach - use a temporary directory
log_message "Attempting backup via temporary directory..."
tmp_backup_dir="/tmp/mongodb_backup_$$"
mkdir -p "$tmp_backup_dir"
"$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" > /dev/null 2>&1 && {
# Copy temporary backup files to the actual backup directory
log_message "Copying backup files from temporary directory..."
cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
db_backup_ok=1
} || {
log_message "ERROR: All attempts to backup database failed"
}
}
else
sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && db_backup_ok=1 || {
log_message "ERROR: Failed to backup database with original path"
# Try an alternative approach - use a temporary directory
log_message "Attempting backup via temporary directory..."
tmp_backup_dir="/tmp/mongodb_backup_$$"
mkdir -p "$tmp_backup_dir"
"$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && {
# Copy temporary backup files to the actual backup directory
log_message "Copying backup files from temporary directory..."
cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
db_backup_ok=1
} || {
log_message "ERROR: All attempts to backup database failed"
}
}
fi
else
log_message "Docker mode selected: skipping host DB backup step"
fi
# Auto-fallback for docker-first environments
if [ "$db_backup_ok" -ne 1 ] && [ "$BACKUP_MODE" != "host" ]; then
if try_backup_db_docker "$BACKUP_DIR/mongodb_backup"; then
db_backup_ok=1
fi
fi
# Check if any CSV files were created during backup
if ! find "$BACKUP_DIR/mongodb_backup" -name "*.csv" -quit; then
log_message "WARNING: No CSV files found in backup directory"
# If we used a temporary directory earlier and it still exists, try to use its contents
if [ -d "$tmp_backup_dir" ]; then
log_message "Backup created in temporary directory, moving to backup location..."
sudo cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
rm -rf "$tmp_backup_dir"
fi
fi
# Reset to more restrictive permissions after backup completes
sudo chmod -R 755 "$BACKUP_DIR/mongodb_backup"
# Verify that backup files were created
csv_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.csv" 2>/dev/null | wc -l)
archive_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.archive.gz" 2>/dev/null | wc -l)
if [ "$csv_count" -gt 0 ] || [ "$archive_count" -gt 0 ]; then
log_message "Database backup successful: ${csv_count} CSV collection(s), ${archive_count} archive file(s)"
else
log_message "WARNING: No database backup files found in backup directory"
fi
# Export legal invoice archive separately with long retention
backup_invoices_archive || log_message "WARNING: Invoice archive export failed"
# Compress the backup
if [ "$COMPRESSION_LEVEL" -gt 0 ]; then
log_message "Compressing backup with level $COMPRESSION_LEVEL..."
# Create compressed archive with pigz when available, otherwise gzip.
if command -v pigz >/dev/null 2>&1; then
gzip_cmd="pigz -${COMPRESSION_LEVEL}"
else
gzip_cmd="gzip -${COMPRESSION_LEVEL}"
fi
if tar --help 2>/dev/null | grep -q -- "--use-compress-program"; then
sudo tar -cf "$BACKUP_ARCHIVE" -I "$gzip_cmd" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || {
log_message "ERROR: Failed to compress backup (tar -I)"
return 1
}
else
GZIP="-${COMPRESSION_LEVEL}" sudo -E tar -czf "$BACKUP_ARCHIVE" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || {
log_message "ERROR: Failed to compress backup (tar + GZIP env)"
return 1
}
fi
# Remove uncompressed directory after successful compression
if [ -f "$BACKUP_ARCHIVE" ]; then
log_message "Backup compressed successfully to $BACKUP_ARCHIVE"
log_message "Removing uncompressed backup directory"
sudo rm -rf "$BACKUP_DIR"
else
log_message "ERROR: Compressed backup file not found"
echo "Error: could not perform invoice backup in auto mode. Docker or host Python with pymongo is required." >&2
return 1
fi
else
log_message "Compression disabled, keeping uncompressed backup"
fi
# Set appropriate permissions
if [ "$COMPRESSION_LEVEL" -gt 0 ]; then
sudo chmod 644 "$BACKUP_ARCHIVE"
else
sudo chmod -R 755 "$BACKUP_DIR"
fi
# Clean up old backups: age-based filter plus minimum keep safeguard
log_message "Cleaning up old backups (keep at least $MIN_KEEP; days>$KEEP_DAYS)..."
# Build list of all backup artifacts (files and directories) sorted newest first
mapfile -t ALL_BACKUPS < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \
\( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \
-printf '%T@\t%p\n' 2>/dev/null | sort -nr | awk -F '\t' '{print $2}')
TOTAL=${#ALL_BACKUPS[@]}
if (( TOTAL > MIN_KEEP )); then
# Determine deletion candidates by age (if KEEP_DAYS>0), otherwise all except the newest MIN_KEEP
if (( KEEP_DAYS > 0 )); then
mapfile -t AGE_CANDIDATES < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \
\( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \
-mtime +"$KEEP_DAYS" -printf '%T@\t%p\n' 2>/dev/null | sort -n | awk -F '\t' '{print $2}')
else
AGE_CANDIDATES=()
# If no age filter, consider everything except the newest MIN_KEEP as candidates (oldest first)
if (( TOTAL > MIN_KEEP )); then
# Reverse ALL_BACKUPS to get oldest first
for ((i=TOTAL-1; i>=MIN_KEEP; i--)); do AGE_CANDIDATES+=("${ALL_BACKUPS[$i]}"); done
;;
docker)
if docker_backup; then
cleanup_old_archives
return 0
fi
fi
# Protect the newest MIN_KEEP backups overall
ALLOWED_DELETE=$(( TOTAL - MIN_KEEP ))
DELETED=0
for path in "${AGE_CANDIDATES[@]:-}"; do
(( DELETED >= ALLOWED_DELETE )) && break
if [ -z "$path" ]; then continue; fi
if [ -f "$path" ]; then
sudo rm -f "$path" && ((DELETED++))
elif [ -d "$path" ]; then
sudo rm -rf "$path" && ((DELETED++))
echo "Error: docker backup failed or app container is unavailable." >&2
return 1
;;
host)
if host_backup; then
cleanup_old_archives
return 0
fi
done
log_message "Deleted $DELETED old backup(s); kept $(( TOTAL - DELETED ))"
else
log_message "No cleanup needed (total backups: $TOTAL <= min-keep: $MIN_KEEP)"
fi
log_message "Backup completed successfully"
return 0
echo "Error: host backup failed. Ensure python3 and pymongo are installed." >&2
return 1
;;
*)
echo "Error: unsupported mode '$MODE'" >&2
usage
return 2
;;
esac
}
# Main
log_message "Backup destination: $BACKUP_BASE_DIR"
log_message "Database: $DB_NAME | URI: $MONGO_URI | Keep days: $KEEP_DAYS | Min keep: $MIN_KEEP | Compression: ${COMPRESSION_LEVEL}"
log_message "Invoice archive: $INVOICE_ARCHIVE_DIR | Invoice retention days: $INVOICE_KEEP_DAYS"
log_message "Backup mode: $BACKUP_MODE"
if [[ "$BACKUP_MODE" != "auto" && "$BACKUP_MODE" != "host" && "$BACKUP_MODE" != "docker" ]]; then
log_message "ERROR: Invalid --mode value '$BACKUP_MODE' (allowed: auto, host, docker)"
exit 2
fi
init_docker_compose || true
if create_backup; then
ART_DATE="$(date +"%Y-%m-%d")"
if [[ "$COMPRESSION_LEVEL" -gt 0 ]]; then
echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE.tar.gz"
else
echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE"
fi
fi
parse_args "$@"
main
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python"
if [ ! -x "$VENV_PYTHON" ]; then
echo "Error: .venv python not found at $VENV_PYTHON"
echo "Create it first, e.g.: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
exit 1
fi
if [ "$(id -u)" -ne 0 ] && [ ! -w "$SCRIPT_DIR/.venv" ]; then
echo "Fixing ownership of .venv for current user..."
if [ -n "$SUDO" ]; then
$SUDO chown -R "$(id -un):$(id -gn)" "$SCRIPT_DIR/.venv"
else
echo "Error: .venv is not writable and sudo is unavailable."
exit 1
fi
fi
for path in "$SCRIPT_DIR/dist" "$SCRIPT_DIR/build"; do
if [ -e "$path" ] && [ "$(id -u)" -ne 0 ]; then
echo "Ensuring ownership of $(basename "$path") for current user..."
if [ -n "$SUDO" ]; then
$SUDO chown -R "$(id -un):$(id -gn)" "$path"
else
if [ ! -w "$path" ]; then
echo "Error: $path is not writable and sudo is unavailable."
exit 1
fi
fi
fi
done
echo "Installing Nuitka build dependencies in .venv..."
"$VENV_PYTHON" -m pip install --upgrade pip setuptools wheel
"$VENV_PYTHON" -m pip install --upgrade "nuitka==2.8.10" ordered-set zstandard
DIST_DIR="$SCRIPT_DIR/dist"
BUILD_DIR="$SCRIPT_DIR/build"
OUTPUT_NAME="inventarsystem"
mkdir -p "$DIST_DIR" "$BUILD_DIR"
NUITKA_DATA_ARGS=()
if [ -d "$SCRIPT_DIR/Web/templates" ]; then
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/templates=templates")
fi
if [ -d "$SCRIPT_DIR/Web/static" ]; then
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/static=static")
fi
if [ -d "$SCRIPT_DIR/uploads" ]; then
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/uploads=uploads")
fi
ORIGINAL_PERF_PARANOID=""
if [ -r /proc/sys/kernel/perf_event_paranoid ]; then
ORIGINAL_PERF_PARANOID="$(cat /proc/sys/kernel/perf_event_paranoid)"
if [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then
echo "Temporarily setting kernel.perf_event_paranoid=1 for Nuitka build compatibility..."
if ! $SUDO sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid'; then
echo "Warning: Could not adjust kernel.perf_event_paranoid automatically."
echo "Run: sudo sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid' and retry."
exit 1
fi
fi
fi
restore_perf_setting() {
if [ -n "$ORIGINAL_PERF_PARANOID" ] && [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then
$SUDO sh -c "echo $ORIGINAL_PERF_PARANOID > /proc/sys/kernel/perf_event_paranoid" >/dev/null 2>&1 || true
fi
}
trap restore_perf_setting EXIT
echo "Building standalone binary with Nuitka..."
"$VENV_PYTHON" -m nuitka \
--standalone \
--assume-yes-for-downloads \
--follow-imports \
--output-dir="$DIST_DIR" \
--output-filename="$OUTPUT_NAME" \
"${NUITKA_DATA_ARGS[@]}" \
--remove-output \
"$SCRIPT_DIR/Web/app.py"
APP_DIST_DIR="$DIST_DIR/app.dist"
if [ -d "$APP_DIST_DIR" ]; then
echo "Nuitka build complete."
echo "Run with: $APP_DIST_DIR/$OUTPUT_NAME"
else
echo "Build finished, but expected output directory not found: $APP_DIST_DIR"
echo "Check Nuitka output above."
exit 1
fi
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
ADMIN_PROJECT_DIR="${ADMIN_PROJECT_DIR:-$(dirname "$PROJECT_DIR")/admin_Inventarsystem}"
DRY_RUN=0
REMOVE_CRON=0
KEEP_AUTOSTART=0
SERVICES=(
"inventarsystem-gunicorn.service"
"admin-inventarsystem-gunicorn.service"
"admin-inventarsystem-nginx.service"
)
SOCKETS=(
"/tmp/inventarsystem.sock"
"/tmp/admin-inventarsystem.sock"
)
PROCESS_PATTERNS=(
"$PROJECT_DIR/.venv/bin/gunicorn app:app"
"$ADMIN_PROJECT_DIR/.venv/bin/gunicorn app:app"
)
CRON_FILTER_PATTERNS=(
"$PROJECT_DIR/update.sh"
"$PROJECT_DIR/backup-docker.sh"
"$ADMIN_PROJECT_DIR/update.sh"
"$ADMIN_PROJECT_DIR/backup-docker.sh"
)
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--remove-cron Remove matching cron entries for old systems
--keep-autostart Stop services but do not disable autostart
--dry-run Show actions without executing
-h, --help Show this help message
EOF
}
log() {
echo "[cleanup-old] $*"
}
run_cmd() {
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] $*"
else
"$@"
fi
}
run_cmd_sudo() {
if [ "$(id -u)" -eq 0 ]; then
run_cmd "$@"
else
run_cmd sudo "$@"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--remove-cron)
REMOVE_CRON=1
shift
;;
--keep-autostart)
KEEP_AUTOSTART=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'"
usage
exit 2
;;
esac
done
}
service_exists() {
local service="$1"
systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}' | grep -Fxq "$service"
}
stop_services() {
if ! command -v systemctl >/dev/null 2>&1; then
log "systemctl not available, skipping service cleanup"
return 0
fi
local service
for service in "${SERVICES[@]}"; do
if ! service_exists "$service"; then
log "Service not found: $service"
continue
fi
log "Stopping $service"
run_cmd_sudo systemctl stop "$service" || true
if [ "$KEEP_AUTOSTART" -eq 0 ]; then
log "Disabling autostart for $service"
run_cmd_sudo systemctl disable "$service" || true
fi
done
}
kill_leftover_processes() {
local pattern
for pattern in "${PROCESS_PATTERNS[@]}"; do
log "Terminating processes matching: $pattern"
run_cmd_sudo pkill -TERM -f "$pattern" || true
done
if [ "$DRY_RUN" -eq 0 ]; then
sleep 2
fi
for pattern in "${PROCESS_PATTERNS[@]}"; do
run_cmd_sudo pkill -KILL -f "$pattern" || true
done
}
remove_stale_sockets() {
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
log "Removing stale socket/file: $socket_path"
run_cmd_sudo rm -f "$socket_path"
else
log "Socket/file not present: $socket_path"
fi
done
}
remove_cron_entries() {
if ! command -v crontab >/dev/null 2>&1; then
log "crontab not available, skipping cron cleanup"
return 0
fi
local tmp_file
tmp_file="$(mktemp)"
if [ "$(id -u)" -eq 0 ]; then
(crontab -l 2>/dev/null || true) > "$tmp_file"
else
(sudo crontab -l 2>/dev/null || true) > "$tmp_file"
fi
local pattern
for pattern in "${CRON_FILTER_PATTERNS[@]}"; do
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] would remove cron lines containing: $pattern"
else
grep -vF "$pattern" "$tmp_file" > "${tmp_file}.new" || true
mv "${tmp_file}.new" "$tmp_file"
fi
done
if [ "$DRY_RUN" -eq 0 ]; then
if [ "$(id -u)" -eq 0 ]; then
crontab "$tmp_file"
else
sudo crontab "$tmp_file"
fi
fi
rm -f "$tmp_file"
log "Cron cleanup finished"
}
status_report() {
log "Remaining matching processes:"
ps -eo pid,ppid,cmd --sort=start_time | grep -E "Inventarsystem/.venv/bin/gunicorn|admin_Inventarsystem/.venv/bin/gunicorn" | grep -v grep || echo "none"
log "Socket status:"
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
echo "exists: $socket_path"
else
echo "missing: $socket_path"
fi
done
}
main() {
parse_args "$@"
stop_services
kill_leftover_processes
remove_stale_sockets
if [ "$REMOVE_CRON" -eq 1 ]; then
remove_cron_entries
fi
status_report
log "Cleanup complete"
}
main "$@"
+89 -29
View File
@@ -1,45 +1,47 @@
{
"dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.0.2",
"ver": "0.6.44",
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "localhost",
"port": 27017,
"db": "Inventarsystem"
},
"scheduler": {
"enabled": true,
"interval_minutes": 1,
"backup_interval_hours": 24
},
"ssl": {
"enabled": true,
"cert": "Web/certs/cert.pem",
"key": "Web/certs/key.pem"
},
"images": {
"thumbnail_size": [150, 150],
"preview_size": [400, 400]
"thumbnail_size": [
150,
150
],
"preview_size": [
400,
400
]
},
"upload": {
"max_size_mb": 10,
"image_max_size_mb": 15,
"video_max_size_mb": 100
},
"paths": {
"backups": "backups",
"logs": "logs"
},
"modules": {
"inventory": {
"enabled": true
},
"library": {
"enabled": true
},
@@ -49,25 +51,83 @@
"max_borrow_days": 365
}
},
"tenants": {
},
"allowed_extensions": [
"png", "jpg", "jpeg", "gif",
"hevc", "heif",
"mp4", "mov", "avi", "mkv", "webm",
"mp3", "wav", "ogg", "flac", "aac", "m4a", "opus",
"pdf", "docx", "xlsx", "pptx", "txt"
"png",
"jpg",
"jpeg",
"gif",
"hevc",
"heif",
"mp4",
"mov",
"avi",
"mkv",
"webm",
"mp3",
"wav",
"ogg",
"flac",
"aac",
"m4a",
"opus",
"pdf",
"docx",
"xlsx",
"pptx",
"txt"
],
"schoolPeriods": {
"1": { "start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)" },
"2": { "start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)" },
"3": { "start": "09:45", "end": "10:30", "label": "3. Stunde (09:45 - 10:30)" },
"4": { "start": "10:30", "end": "11:15", "label": "4. Stunde (10:30 - 11:15)" },
"5": { "start": "11:30", "end": "12:15", "label": "5. Stunde (11:30 - 12:15)" },
"6": { "start": "12:15", "end": "13:00", "label": "6. Stunde (12:15 - 13:00)" },
"7": { "start": "13:30", "end": "14:15", "label": "7. Stunde (13:30 - 14:15)" },
"8": { "start": "14:15", "end": "15:00", "label": "8. Stunde (14:15 - 15:00)" },
"9": { "start": "15:15", "end": "16:00", "label": "9. Stunde (15:15 - 16:00)" },
"10": { "start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)" }
"1": {
"start": "08:00",
"end": "08:45",
"label": "1. Stunde (08:00 - 08:45)"
},
"2": {
"start": "08:45",
"end": "09:30",
"label": "2. Stunde (08:45 - 09:30)"
},
"3": {
"start": "09:45",
"end": "10:30",
"label": "3. Stunde (09:45 - 10:30)"
},
"4": {
"start": "10:30",
"end": "11:15",
"label": "4. Stunde (10:30 - 11:15)"
},
"5": {
"start": "11:30",
"end": "12:15",
"label": "5. Stunde (11:30 - 12:15)"
},
"6": {
"start": "12:15",
"end": "13:00",
"label": "6. Stunde (12:15 - 13:00)"
},
"7": {
"start": "13:30",
"end": "14:15",
"label": "7. Stunde (13:30 - 14:15)"
},
"8": {
"start": "14:15",
"end": "15:00",
"label": "8. Stunde (14:15 - 15:00)"
},
"9": {
"start": "15:15",
"end": "16:00",
"label": "9. Stunde (15:15 - 16:00)"
},
"10": {
"start": "16:00",
"end": "16:45",
"label": "10. Stunde (16:00 - 16:45)"
}
}
}
}
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Debug script to check why planned bookings are not being activated
"""
import sys
import os
import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'Web'))
from pymongo import MongoClient
import settings as cfg
import ausleihung as au
def check_bookings():
"""Check all planned bookings and their status"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
# Get all planned bookings
planned = list(ausleihungen.find({'Status': 'planned'}))
print(f"\n📊 Gefundene GEPLANTE Ausleihen: {len(planned)}\n")
print("=" * 100)
current_time = datetime.datetime.now()
print(f"⏰ Aktuelle Zeit: {current_time}\n")
for booking in planned:
booking_id = str(booking['_id'])
user = booking.get('User', 'N/A')
item = booking.get('Item', 'N/A')
start = booking.get('Start')
end = booking.get('End')
period = booking.get('Period')
status = booking.get('Status')
print(f"\n🔹 Ausleihe ID: {booking_id}")
print(f" Benutzer: {user}")
print(f" Item: {item}")
print(f" Status: {status}")
print(f" Schulstunde/Periode: {period}")
print(f" Start: {start} (Typ: {type(start).__name__})")
print(f" Ende: {end} (Typ: {type(end).__name__})")
# Check current status
current_status = au.get_current_status(booking, log_changes=False)
print(f" ✓ Berechneter Status: {current_status}")
if current_status != status:
print(f" ⚠️ STATUS STIMMT NICHT ÜBEREIN! Sollte sein: {current_status}")
# Check time comparison
if start:
time_diff = (current_time - start).total_seconds()
if time_diff < 0:
print(f" ⏳ Startet in: {abs(time_diff) / 60:.1f} Minuten")
elif time_diff < 3600:
print(f" ✓ Sollte JETZT AKTIV sein! ({time_diff / 60:.1f} Minuten vorbei)")
else:
print(f" ✓ Sollte schon {time_diff / 3600:.1f} Stunden aktiv sein!")
print("\n" + "=" * 100)
# Check active bookings too
active = list(ausleihungen.find({'Status': 'active'}))
print(f"\n✓ AKTIVE Ausleihen: {len(active)}")
for booking in active:
print(f" - {booking.get('User', 'N/A')}: {booking.get('Item', 'N/A')} (Periode: {booking.get('Period')})")
client.close()
if __name__ == '__main__':
check_bookings()
+35 -45
View File
@@ -1,42 +1,26 @@
# Multi-Tenant Optimized Docker Compose
# Supports running multiple isolated app instances per subdomain
# Supports running multiple isolated app instances with direct Docker host port binding
# Each instance: ~50MB base + 20-30MB per 20 users
#
# Usage:
# docker-compose -f docker-compose-multitenant.yml up -d
#
# Scale example: To support 10 tenants with 20 users each:
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
version: '3.9'
# Example: Start the stack and expose the app on host port 10000
# INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
app:
condition: service_started
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
# Management Container for multi-tenant scripts
tenant-manager:
image: python:3.12-alpine
container_name: tenant-manager
restart: "no"
profiles:
- tools
volumes:
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./docker/nginx/acme:/etc/nginx/acme:ro
networks:
- inventar-net
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
environment:
NGINX_WORKER_PROCESSES: auto
NGINX_WORKER_CONNECTIONS: 1024
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace
entrypoint: >
sh -c "apk add --no-cache bash docker-cli docker-cli-compose && cd /workspace && ./manage-tenant.sh \"$$@\"" --
redis:
image: redis:7-alpine
@@ -59,9 +43,8 @@ services:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db
ports:
- "27017:27017"
expose:
- "27017"
volumes:
- mongodb_data:/data/db
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
@@ -82,6 +65,8 @@ services:
args:
PYTHON_VERSION: "3.13"
OPTIMIZATION_LEVEL: 2
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
restart: unless-stopped
security_opt:
- no-new-privileges:true
@@ -90,13 +75,14 @@ services:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
networks:
- inventar-net
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads:cached
- app_thumbnails:/app/Web/thumbnails:cached
- app_previews:/app/Web/previews:cached
@@ -114,6 +100,7 @@ services:
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_DB: "0"
INVENTAR_TENANT_PORT_MAP: "${INVENTAR_TENANT_PORT_MAP:-}"
# Storage Configuration
INVENTAR_BACKUP_FOLDER: /data/backups
@@ -121,11 +108,16 @@ services:
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
# Performance Tuning
INVENTAR_WORKER_CLASS: gevent
INVENTAR_WORKERS: "4"
INVENTAR_THREADS: "2"
INVENTAR_WORKER_TIMEOUT: "30"
INVENTAR_WORKER_CONNECTIONS: "100"
INVENTAR_WORKER_CLASS: ${INVENTAR_WORKER_CLASS:-gevent}
INVENTAR_WORKERS: "${INVENTAR_WORKERS:-4}"
INVENTAR_THREADS: "${INVENTAR_THREADS:-2}"
INVENTAR_WORKER_TIMEOUT: "${INVENTAR_WORKER_TIMEOUT:-30}"
INVENTAR_WORKER_CONNECTIONS: "${INVENTAR_WORKER_CONNECTIONS:-100}"
# Auto-scaled runtime settings
INVENTAR_APP_CPUS: ${INVENTAR_APP_CPUS:-1.0}
INVENTAR_APP_MEM_LIMIT: ${INVENTAR_APP_MEM_LIMIT:-256m}
INVENTAR_APP_MEM_SWAP_LIMIT: ${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}
# Multi-Tenant
INVENTAR_MULTITENANT_ENABLED: "true"
@@ -146,9 +138,9 @@ services:
# Resource Limits (per instance)
# With 10 instances: each gets ~150MB, totaling ~1.5GB
# Adjust based on server resources
mem_limit: 256m
memswap_limit: 512m
cpus: "1.0"
mem_limit: "${INVENTAR_APP_MEM_LIMIT:-256m}"
memswap_limit: "${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}"
cpus: "${INVENTAR_APP_CPUS:-1.0}"
# Health check for load balancer
healthcheck:
@@ -181,9 +173,6 @@ volumes:
networks:
inventar-net:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
# Scale guidance:
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
@@ -196,3 +185,4 @@ networks:
# - Medium (5-10 tenants): 4GB RAM, 2-4 CPU cores
# - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores
# - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering
+26 -72
View File
@@ -1,87 +1,41 @@
version: "3.8"
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
app:
build: .
container_name: inventory-app
restart: unless-stopped
environment:
- MONGO_URL=mongodb://mongodb:27017/inventar
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
depends_on:
app:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- mongodb
- redis
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
image: mongo:latest
container_name: mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
- mongo_data:/data/db
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
image: redis:alpine
container_name: redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
app:
build:
context: .
dockerfile: Dockerfile
container_name: inventarsystem-app
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
security_opt:
- no-new-privileges:true
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_CACHE_DB: "1"
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
expose:
- "8000"
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
- ./config.yml:/etc/cloudflared/config.yml
- ./credentials.json:/etc/cloudflared/credentials.json
depends_on:
- app
volumes:
mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
redis_data:
mongo_data:
+8
View File
@@ -0,0 +1,8 @@
tunnel: homeserver
credentials-file: /etc/cloudflared/credentials.json
ingress:
- service: https://nginx:443
originRequest:
noTLSVerify: true
- service: http_status:404
+5 -4
View File
@@ -37,7 +37,8 @@ upstream inventar_backend {
# Main HTTPS server block
server {
listen 443 ssl http2;
listen 443 ssl;
http2 on;
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
# SSL Configuration (wildcard certificate)
@@ -75,9 +76,9 @@ server {
application/x-javascript application/xml+rss application/json;
gzip_comp_level 5;
# Access/Error logs with tenant in filename
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k;
error_log /var/log/nginx/inventar_error_${tenant}.log warn;
# Access/Error logs
access_log /var/log/nginx/inventar_access.log combined buffer=32k;
error_log /var/log/nginx/inventar_error.log warn;
# Root location - proxy to app with tenant context
location / {
-1104
View File
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
set -euo pipefail
# Initialize first admin user if database is empty
# Usage: ./init-admin.sh [username] [password] [first_name] [last_name]
# Default: admin / admin123456 / Admin / User
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
USERNAME="${1:-admin}"
PASSWORD="${2:-admin123456}"
FIRST_NAME="${3:-Admin}"
LAST_NAME="${4:-User}"
# Wait for MongoDB to be ready
echo "Waiting for MongoDB to be ready..."
for i in {1..30}; do
if docker exec inventarsystem-mongodb mongosh --eval "db.adminCommand('ping')" >/dev/null 2>&1; then
echo "MongoDB is ready."
break
fi
if [ $i -eq 30 ]; then
echo "Error: MongoDB did not start in time."
exit 1
fi
sleep 1
done
# Check if any users already exist
USER_COUNT=$(docker exec inventarsystem-mongodb mongosh --quiet --eval "db.users.countDocuments({})" Inventarsystem 2>/dev/null || echo "0")
if [ "$USER_COUNT" -eq 0 ]; then
echo "Database is empty. Creating initial admin user: $USERNAME"
# Hash the password
PASSWORD_HASH=$(python3 -c "import hashlib; print(hashlib.sha512(b'$PASSWORD').hexdigest())")
# Insert admin user
docker exec inventarsystem-mongodb mongosh --eval "
db.users.insertOne({
Username: '$USERNAME',
Password: '$PASSWORD_HASH',
Admin: true,
active_ausleihung: null,
name: '$FIRST_NAME',
last_name: '$LAST_NAME',
favorites: []
})
" Inventarsystem
echo "✓ Admin user '$USERNAME' created successfully."
echo " Username: $USERNAME"
echo " Password: $PASSWORD"
else
echo "Database already has $USER_COUNT user(s). Skipping initialization."
fi
+24 -10
View File
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR=""
COMPOSE_FILE="docker-compose-multitenant.yml"
cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
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
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
if [ ! -f "$compose_file" ]; then
continue
fi
python3 - <<'PY' "$compose_file" "$tag"
python3 - <<'PY' "$compose_file" "$tag"
import re
import sys
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out)
PY
done
}
install_docker_if_missing() {
@@ -173,6 +175,7 @@ Options:
--remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration
@@ -199,6 +202,10 @@ parse_args() {
CLEANUP_OLD_REMOVE_CRON=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--legacy-db-name)
LEGACY_DB_NAME="$2"
shift 2
@@ -280,14 +287,21 @@ backup_legacy_database() {
restore_legacy_backup_into_docker() {
local i
local compose_path
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
return 0
fi
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
echo "Error: compose file not found: $compose_path"
exit 1
fi
echo "Waiting for Docker MongoDB to become ready..."
for i in $(seq 1 60); do
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
break
fi
sleep 2
@@ -299,7 +313,7 @@ restore_legacy_backup_into_docker() {
fi
echo "Importing legacy backup into Docker MongoDB..."
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
echo "Legacy DB import completed."
}
@@ -454,8 +468,8 @@ EOF
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=10001
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"
+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Script to manage multitenant deployment
# Allows adding, removing, and restarting tenants without downtime for others
if [ ! -f "docker-compose-multitenant.yml" ]; then
echo "Error: docker-compose-multitenant.yml not found."
exit 1
fi
CONFIG_FILE="$PWD/config.json"
show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo ""
echo "Commands:"
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh module school_a inventory=off library=on"
echo " ./manage-tenant.sh restart-all"
echo " ./manage-tenant.sh -h"
exit 1
}
register_tenant_port() {
local tenant_id="$1"
local port="$2"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port"
import json, sys, os
path, tenant_id, port_str = sys.argv[1], sys.argv[2], sys.argv[3]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
for tid, conf in tenants.items():
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
existing = tenants.get(tenant_id)
if existing is None or not isinstance(existing, dict):
existing = {}
existing['port'] = int(port_str)
tenants[tenant_id] = existing
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Registered tenant port {port_str} for {tenant_id}")
PY
then
echo "Tenant $tenant_id port $port registered in config.json"
else
echo "Failed to register tenant port $port for $tenant_id"
exit 1
fi
}
update_runtime_ports() {
local new_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local unique_ports=()
local first_port=""
if [ -n "$new_port" ]; then
if [ -f "$env_file" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
fi
if [ -n "$current_ports" ]; then
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
fi
port_list+=("$new_port")
for port in "${port_list[@]}"; do
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
unique_ports+=("$port")
fi
done
if [ ${#unique_ports[@]} -eq 0 ]; then
unique_ports=("$new_port")
fi
first_port="${unique_ports[0]}"
local ports_csv
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$first_port
INVENTAR_HTTP_PORTS=$ports_csv
EOF
else
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
fi
echo "Updated runtime env ports: $ports_csv"
fi
}
sync_tenant_port_map() {
local config_path="$CONFIG_FILE"
local env_file="$PWD/.docker-build.env"
local tenant_port_map
tenant_port_map="$(python3 - <<'PY' "$config_path"
import json, os, sys
path = sys.argv[1]
if not os.path.isfile(path):
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict):
sys.exit(0)
entries = []
for tenant_id, conf in sorted(tenants.items()):
if isinstance(conf, dict):
port = conf.get('port')
if isinstance(port, (int, float)) or (isinstance(port, str) and str(port).strip().isdigit()):
entries.append(f"{int(port)}={tenant_id}")
print(','.join(entries))
PY
)"
if [ -z "$tenant_port_map" ]; then
if [ -f "$env_file" ]; then
sed -i '/^INVENTAR_TENANT_PORT_MAP=/d' "$env_file"
fi
return 0
fi
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
NUITKA_BUILD=0
INVENTAR_TENANT_PORT_MAP=$tenant_port_map
EOF
return 0
fi
if grep -q '^INVENTAR_TENANT_PORT_MAP=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_TENANT_PORT_MAP=.*|INVENTAR_TENANT_PORT_MAP=$tenant_port_map|" "$env_file"
else
printf '\nINVENTAR_TENANT_PORT_MAP=%s\n' "$tenant_port_map" >> "$env_file"
fi
}
restart_app_container() {
local env_file="$PWD/.docker-build.env"
local compose_args=( -f "$PWD/docker-compose-multitenant.yml" )
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
fi
if [ -f "$env_file" ]; then
compose_args+=( --env-file "$env_file" )
fi
echo "Restarting app container to apply tenant configuration changes..."
docker compose "${compose_args[@]}" up -d --no-build --force-recreate app
}
remove_tenant_port() {
local tenant_id="$1"
local config_path="$CONFIG_FILE"
local port
port="$(python3 - "$config_path" "$tenant_id" <<'PY'
import json, sys, os
path, tenant_id = sys.argv[1], sys.argv[2]
if not os.path.isfile(path):
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
sys.exit(2)
removed = tenants.pop(tenant_id, None)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
if removed is not None:
port = removed.get('port')
if port is not None:
print(port)
PY
)"
local status=$?
if [ $status -eq 1 ]; then
echo "Error: config file not found: $config_path"
return 1
fi
if [ $status -eq 2 ]; then
return 2
fi
if [ -n "$port" ]; then
echo "$port"
fi
return 0
}
remove_runtime_port() {
local removed_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local new_ports=()
local first_port=""
if [ ! -f "$env_file" ]; then
return 0
fi
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
if [ -z "$current_ports" ]; then
return 0
fi
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
for port in "${port_list[@]}"; do
if [ -n "$port" ] && [ "$port" != "$removed_port" ]; then
new_ports+=("$port")
fi
done
if [ ${#new_ports[@]} -eq 0 ]; then
sed -i '/^INVENTAR_HTTP_PORTS=/d;/^INVENTAR_HTTP_PORT=/d' "$env_file"
return 0
fi
first_port="${new_ports[0]}"
local ports_csv="$(IFS=,; echo "${new_ports[*]}")"
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
}
if [ -z "${1:-}" ]; then
show_help
fi
COMMAND="$1"
TENANT_ID="${2:-}"
case "$COMMAND" in
-h|--help)
show_help
;;
add)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
PORT_ARG="$3"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
fi
echo "Adding new tenant '$TENANT_ID'..."
# Initialize tenant database via Python inside container
echo "Initializing database for $TENANT_ID..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}'
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
if db.users.count_documents({'Username': 'admin'}) == 0:
db.users.insert_one({
'Username': 'admin',
'Password': hashed_pw,
'Admin': True,
'active_ausleihung': None,
'name': 'Admin',
'last_name': 'User',
'IsStudent': False,
'PermissionPreset': 'full_access',
'ActionPermissions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'PagePermissions': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'manage_filters': True,
'manage_locations': True,
},
})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
else
echo "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant."
fi
;;
remove)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id to remove."
exit 1
fi
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
read confirm
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
echo "Removing tenant '$TENANT_ID'..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); from tenant import TenantContext; import settings; from pymongo import MongoClient
ctx = TenantContext()
db_name = ctx._get_db_name(sys.argv[1])
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(db_name)
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' database removed."
else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
fi
port_to_remove=""
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
else
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
fi
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
else
echo "Removal canceled."
fi
;;
restart-tenant)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
# To restart a single tenant without restarting the global python processes,
# we can invalidate their cache or drop their sessions collection to sign everyone out
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.sessions.drop() # Force sign-out / session clear
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
else
echo "Error: Application container not running."
fi
;;
restart-all)
echo "Restarting all application instances with zero-downtime rolling restart..."
docker restart $(docker ps -qf "name=app")
echo "Global restart complete."
;;
list)
echo "Listing configured tenants (config.json):"
if [ -f "$CONFIG_FILE" ]; then
python3 - "$CONFIG_FILE" <<'PY'
import json, sys
path = sys.argv[1]
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if isinstance(tenants, dict) and tenants:
for tid, conf in tenants.items():
port = conf.get('port') if isinstance(conf, dict) else None
if port is not None:
print(f'- {tid} (port {port})')
else:
print(f'- {tid}')
else:
print(' (no tenants configured)')
PY
else
echo " (config.json not found)"
fi
echo ""
echo "Listing active tenant databases (MongoDB):"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
import sys
sys.path.insert(0, '/app/Web')
import settings
from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = 'inventar_'
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
if dbs:
for db in dbs:
print(f'- {db.replace(prefix, "")}')
else:
print(' (no tenant databases found)')
PY
else
echo "Error: Application container not running."
fi
;;
module)
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
exit 1
fi
# Pass all remaining arguments to python script
shift 2
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
import json, sys, os
path = sys.argv[1]
tenant_id = sys.argv[2]
module_args = sys.argv[3:]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {})
if 'port' not in tenant_cfg:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
modules = tenant_cfg.setdefault('modules', {})
for arg in module_args:
if '=' not in arg:
print(f"Warning: Ignoring invalid argument format '{arg}'. Expected name=on|off.", file=sys.stderr)
continue
mod_name, state_str = arg.split('=', 1)
state = str(state_str).lower() in ('on', '1', 'true', 'yes')
module_cfg = modules.setdefault(mod_name, {})
module_cfg['enabled'] = state
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
PY
then
echo "Module configurations updated successfully."
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
else
echo "Failed to update module configuration."
exit 1
fi
;;
*)
echo "Unknown command: $COMMAND"
show_help
;;
esac
exit 0
-337
View File
@@ -1,337 +0,0 @@
#!/bin/bash
# Standalone version manager for this repo
# Supports pinning to a commit/tag/branch, one-time use, clearing the lock,
# applying the lock, listing refs, and showing status. Optional restart.
set -euo pipefail
PROJECT_DIR="$(dirname "$(readlink -f "$0")")"
cd "$PROJECT_DIR"
LOCK_FILE="$PROJECT_DIR/.version-lock"
RESTART=false
FORCE=false
BACKUP_BASE_DIR="/var/backups"
LOG_DIR="$PROJECT_DIR/logs"
VM_LOG="$LOG_DIR/version_manager.log"
# Directories/files to preserve across version switches
# Data paths we want to restore from backup after a version switch
DATA_PATHS=(
"certs"
"Images"
"logs"
"Web/uploads"
"Web/thumbnails"
"Web/QRCodes"
"Web/previews"
)
# Ensure log directory exists
mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true
chmod 777 "$LOG_DIR" >/dev/null 2>&1 || true
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$VM_LOG"; }
err() { echo "ERROR: $*" | tee -a "$VM_LOG" >&2; }
usage() {
cat <<USAGE
Usage:
$0 pin <ref> [--restart] [--force] Persistently lock to a commit/tag/branch and deploy it
$0 use <ref> [--restart] [--force] Use ref once (no persistence) and deploy it
$0 clear [--restart] [--force] Remove lock, switch back to main, pull latest
$0 apply [--restart] [--force] Re-apply the current lock (if any)
$0 status Show current commit and lock state
$0 list [--tags|--commits] List tags or recent commits (default: tags)
Notes:
- <ref> can be a tag name, branch name, or full/short commit hash
- --restart calls ./restart.sh after switching
- --force discards local changes (git reset --hard) if needed
USAGE
}
ensure_git_ready() {
# Avoid "dubious ownership" errors
git config --global --add safe.directory "$PROJECT_DIR" >/dev/null 2>&1 || true
# Fetch refs and tags
git fetch --all --tags --prune
}
ensure_clean_or_force() {
if ! git diff --quiet || ! git diff --cached --quiet; then
if [ "$FORCE" = true ]; then
log "Discarding local changes (force)"
git reset --hard
git clean -fdx >/dev/null 2>&1 || true
else
err "Working tree has local changes. Re-run with --force to discard."
exit 2
fi
fi
}
restart_if_requested() {
if [ "$RESTART" = true ]; then
if [ -x "$PROJECT_DIR/restart.sh" ]; then
log "Restarting services..."
"$PROJECT_DIR/restart.sh"
else
log "restart.sh not found or not executable; skipping restart"
fi
fi
}
# Create a full backup before switching versions (files + DB CSVs)
LAST_BACKUP_ARCHIVE=""
LAST_BACKUP_DIRNAME=""
create_pre_switch_backup() {
local ts backup_name backup_dir backup_archive
ts=$(date +"%Y-%m-%d_%H-%M-%S")
backup_name="Inventarsystem-pre-switch-$ts"
backup_dir="$BACKUP_BASE_DIR/$backup_name"
backup_archive="$BACKUP_BASE_DIR/$backup_name.tar.gz"
log "Creating pre-switch backup at $backup_archive"
sudo mkdir -p "$backup_dir" || { err "Failed to create backup directory"; return 1; }
# Copy project files (exclude .venv to reduce size, keep .git for reference)
log "Copying project files to backup directory..."
sudo rsync -a --delete \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
"$PROJECT_DIR/" "$backup_dir/" || log "Warning: rsync file copy encountered issues"
# Database CSV backup using existing helper
log "Backing up MongoDB to CSVs..."
sudo mkdir -p "$backup_dir/mongodb_backup" || true
if [ -x "$PROJECT_DIR/run-backup.sh" ]; then
if sudo -E "$PROJECT_DIR/run-backup.sh" --db Inventarsystem --uri mongodb://localhost:27017/ --out "$backup_dir/mongodb_backup" >> "$VM_LOG" 2>&1; then
log "Database backup completed"
else
err "Database backup failed; continuing with file backup only"
fi
else
log "run-backup.sh not found; skipping DB backup"
fi
# Compress backup
log "Compressing backup archive..."
if sudo tar -czf "$backup_archive" -C "$BACKUP_BASE_DIR" "$backup_name"; then
log "Backup archived to $backup_archive"
sudo rm -rf "$backup_dir" || true
# Set readable permissions
sudo chmod 644 "$backup_archive" || true
# Export for subsequent restore step
LAST_BACKUP_ARCHIVE="$backup_archive"
LAST_BACKUP_DIRNAME="$backup_name"
else
err "Failed to create compressed backup archive"
return 1
fi
}
# Restore key data from the backup archive into current working tree
restore_from_backup_archive() {
if [ -z "$LAST_BACKUP_ARCHIVE" ] || [ ! -f "$LAST_BACKUP_ARCHIVE" ]; then
log "No backup archive available to restore data from; skipping data restore"
return 0
fi
local tmpdir
tmpdir=$(mktemp -d /tmp/inventarsystem_restore_XXXXXX)
log "Extracting backup archive $LAST_BACKUP_ARCHIVE to $tmpdir"
if ! sudo tar -xzf "$LAST_BACKUP_ARCHIVE" -C "$tmpdir"; then
err "Failed to extract backup archive; skipping data restore"
rm -rf "$tmpdir" || true
return 1
fi
local src_root="$tmpdir/$LAST_BACKUP_DIRNAME"
# Some backups might extract without top-level dir; fallback
if [ ! -d "$src_root" ]; then
src_root="$tmpdir"
fi
log "Restoring data directories from backup..."
for rel in "${DATA_PATHS[@]}"; do
if [ -e "$src_root/$rel" ]; then
mkdir -p "$PROJECT_DIR/$(dirname "$rel")" 2>/dev/null || true
rsync -a "$src_root/$rel" "$PROJECT_DIR/$(dirname "$rel")/" >> "$VM_LOG" 2>&1 || log "Warning: failed to restore $rel"
log "Restored: $rel"
else
log "Not in backup (skipped): $rel"
fi
done
rm -rf "$tmpdir" || true
}
# Ensure permissions on critical data directories
ensure_permissions() {
# Logs should be writable
mkdir -p "$PROJECT_DIR/logs" && chmod 777 "$PROJECT_DIR/logs" 2>/dev/null || true
# Web data directories readable/executable by server
for d in "$PROJECT_DIR/Web/uploads" "$PROJECT_DIR/Web/thumbnails" "$PROJECT_DIR/Web/QRCodes" "$PROJECT_DIR/Web/previews" "$PROJECT_DIR/Images"; do
[ -d "$d" ] && chmod -R 755 "$d" 2>/dev/null || true
done
# SSL cert perms (key must be 600)
if [ -f "$PROJECT_DIR/certs/inventarsystem.key" ]; then
chmod 600 "$PROJECT_DIR/certs/inventarsystem.key" 2>/dev/null || true
fi
}
checkout_ref() {
local ref="$1"
# Try tag
if git show-ref --verify --quiet "refs/tags/$ref"; then
git checkout -B locked-tag-"$ref" "tags/$ref"
return 0
fi
# Try remote branch
if git show-ref --verify --quiet "refs/remotes/origin/$ref"; then
git checkout -B locked-branch-"$ref" "origin/$ref"
return 0
fi
# Try local branch
if git show-ref --verify --quiet "refs/heads/$ref"; then
git checkout "$ref"
return 0
fi
# Try commit-ish
if git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
git checkout -B locked-commit "$ref"
return 0
fi
err "Could not resolve ref '$ref' to tag/branch/commit"
exit 3
}
deploy_main_latest() {
ensure_clean_or_force
if git show-ref --verify --quiet refs/heads/main; then
git checkout main
else
git checkout -B main origin/main
fi
git pull --ff-only
}
cmd_status() {
local head_commit
head_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "Current commit: $head_commit"
if [ -f "$LOCK_FILE" ]; then
echo "Version lock: $(cat "$LOCK_FILE")"
else
echo "Version lock: none (tracking main)"
fi
}
cmd_list() {
local mode="tags"
for a in "$@"; do
case "$a" in
--tags) mode="tags" ;;
--commits) mode="commits" ;;
esac
done
ensure_git_ready
if [ "$mode" = "tags" ]; then
git tag --list | sort -V
else
git --no-pager log --oneline -n 30
fi
}
cmd_pin() {
local ref="$1"
ensure_git_ready
# Always produce a backup before switching
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
echo "$ref" > "$LOCK_FILE"
checkout_ref "$ref"
# Restore data from the backup archive
restore_from_backup_archive
ensure_permissions
log "Pinned to: $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_use() {
local ref="$1"
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
checkout_ref "$ref"
restore_from_backup_archive
ensure_permissions
log "Using (one-time): $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_clear() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
[ -f "$LOCK_FILE" ] && rm -f "$LOCK_FILE"
deploy_main_latest
restore_from_backup_archive
ensure_permissions
log "Cleared version lock; now on main @ $(git rev-parse --short HEAD)"
restart_if_requested
}
cmd_apply() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
if [ -f "$LOCK_FILE" ]; then
ensure_clean_or_force
local ref
ref=$(cat "$LOCK_FILE")
checkout_ref "$ref"
log "Applied lock: $ref (commit $(git rev-parse --short HEAD))"
else
log "No lock present; keeping main"
deploy_main_latest
fi
restore_from_backup_archive
ensure_permissions
restart_if_requested
}
# Parse global flags that can appear after subcommand too
parse_tail_flags() {
for a in "$@"; do
case "$a" in
--restart) RESTART=true ;;
--force) FORCE=true ;;
esac
done
}
main() {
local cmd="${1:-}"; shift || true
case "$cmd" in
pin)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_pin "$ref" ;;
use)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_use "$ref" ;;
clear)
parse_tail_flags "$@"; cmd_clear ;;
apply)
parse_tail_flags "$@"; cmd_apply ;;
status)
cmd_status ;;
list)
cmd_list "$@" ;;
--help|-h|help|"")
usage ;;
*)
err "Unknown command: $cmd"; usage; exit 1 ;;
esac
}
main "$@"
-426
View File
@@ -1,426 +0,0 @@
#!/bin/bash
##############################################################################
# Multi-Tenant Migration Script
#
# Automatisierte Konvertierung einer Single-Instance App zu Multi-Tenant
# Führt folgende Operationen durch:
# 1. Backup der Original-Dateien
# 2. Import von Tenant-Modulen
# 3. Anpassung von app.py
# 4. Konfiguration von Redis
# 5. Validierung
#
# Usage: bash migrate-to-multitenant.sh [dry-run]
##############################################################################
set -e
# Farben für Terminal Output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEB_DIR="$PROJECT_ROOT/Web"
BACKUP_DIR="$PROJECT_ROOT/.migration-backup-$(date +%Y%m%d-%H%M%S)"
DRY_RUN="${1:-}"
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[✓]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_prerequisites() {
log_info "Checking prerequisites..."
# Check Python
if ! command -v python3 &> /dev/null; then
log_error "Python 3 not found"
return 1
fi
log_success "Python 3 found: $(python3 --version)"
# Check Docker
if ! command -v docker &> /dev/null; then
log_warning "Docker not found (will be needed for deployment)"
else
log_success "Docker found: $(docker --version)"
fi
# Check Flask app
if [[ ! -f "$WEB_DIR/app.py" ]]; then
log_error "Web/app.py not found"
return 1
fi
log_success "Flask app found at $WEB_DIR/app.py"
return 0
}
create_backups() {
log_info "Creating backups..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would backup to: $BACKUP_DIR"
return 0
fi
mkdir -p "$BACKUP_DIR"
# Backup critical files
cp "$WEB_DIR/app.py" "$BACKUP_DIR/app.py.bak"
cp "$WEB_DIR/settings.py" "$BACKUP_DIR/settings.py.bak" 2>/dev/null || true
cp "docker-compose.yml" "$BACKUP_DIR/docker-compose.yml.bak" 2>/dev/null || true
log_success "Backups created at: $BACKUP_DIR"
}
check_tenant_modules() {
log_info "Checking tenant modules..."
local missing=0
for module in tenant session_manager query_cache; do
if [[ ! -f "$WEB_DIR/${module}.py" ]]; then
log_warning "Missing module: $WEB_DIR/${module}.py"
((missing++))
else
log_success "Module found: $module.py"
fi
done
if [[ $missing -gt 0 ]]; then
log_error "Missing $missing required modules. Ensure they are created first."
return 1
fi
return 0
}
check_docker_files() {
log_info "Checking Docker configuration files..."
local missing=0
if [[ ! -f "docker-compose-multitenant.yml" ]]; then
log_warning "Missing docker-compose-multitenant.yml"
((missing++))
else
log_success "docker-compose-multitenant.yml found"
fi
if [[ ! -f "docker/nginx/multitenant.conf" ]]; then
log_warning "Missing docker/nginx/multitenant.conf"
((missing++))
else
log_success "docker/nginx/multitenant.conf found"
fi
if [[ $missing -gt 0 ]]; then
log_warning "Missing $missing Docker files (needed for deployment)"
fi
return 0
}
update_app_py() {
log_info "Updating app.py with Multi-Tenant imports..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would add Multi-Tenant imports to app.py"
return 0
fi
# Check if already updated
if grep -q "from tenant import" "$WEB_DIR/app.py"; then
log_warning "app.py already contains Multi-Tenant imports (skipping)"
return 0
fi
# Add imports after other data_protection imports
local imports_section=$(python3 -c "
import re
with open('$WEB_DIR/app.py', 'r') as f:
content = f.read()
# Find the line after data_protection imports
if 'from data_protection import' in content:
lines = content.split('\n')
for i, line in enumerate(lines):
if 'from data_protection import' in line:
# Find the end of this import block
j = i + 1
while j < len(lines) and (lines[j].startswith(' ') or lines[j].strip() == ''):
j += 1
# Insert new imports before settings import
print(j)
break
else:
print(-1)
" 2>/dev/null)
if [[ $imports_section -eq -1 ]]; then
log_warning "Could not find insertion point for Multi-Tenant imports"
log_info "Please manually add the following imports to app.py:"
cat << 'EOF'
# Multi-Tenant Imports
from tenant import get_tenant_context, require_tenant, get_tenant_db
from session_manager import create_redis_session_interface
from query_cache import get_cache_manager, cached_query, invalidate_cache
EOF
return 1
fi
log_success "Multi-Tenant imports prepared"
return 0
}
update_requirements() {
log_info "Checking requirements.txt for Redis..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would update requirements.txt"
return 0
fi
# Check both root and Web/requirements.txt
for req_file in requirements.txt Web/requirements.txt; do
if [[ -f "$req_file" ]]; then
if ! grep -q "^redis" "$req_file"; then
log_info "Adding redis to $req_file..."
echo "redis>=7.0.0" >> "$req_file"
log_success "Added redis to $req_file"
else
log_success "$req_file already has redis"
fi
fi
done
return 0
}
generate_migration_guide() {
log_info "Generating migration guide..."
cat > "$BACKUP_DIR/MIGRATION_NOTES.md" << 'EOF'
# Multi-Tenant Migration Checklist
## Automated Steps Completed
- [x] Backup created
- [x] Tenant modules verified
- [x] Docker files prepared
- [x] app.py imports prepared
- [x] requirements.txt updated
## Manual Steps Required
### 1. Update app.py Configuration (5 min)
Add after `app = Flask(...)`:
```python
# Multi-Tenant Configuration
if os.getenv('INVENTAR_SESSION_BACKEND') == 'redis':
try:
app.session_interface = create_redis_session_interface(app)
app.logger.info("Redis session backend enabled")
except Exception as e:
app.logger.warning(f"Redis session backend failed: {e}")
```
### 2. Add Health Check Endpoint (5 min)
Add this route:
```python
@app.route('/health')
def health_check():
try:
mongo = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
mongo.admin.command('ping')
return {'status': 'healthy'}, 200
except Exception as e:
return {'status': 'unhealthy'}, 503
```
### 3. Update Database Access (10-30 min)
Change existing routes to use:
```python
@require_tenant
def your_route():
db = get_tenant_db(MongoClient(...))
# Use db as usual
```
### 4. Setup DNS (5 min)
Create wildcard DNS record:
```
*.example.com IN A your-server-ip
```
### 5. Create SSL Certificate (10 min)
```bash
# Let's Encrypt (recommended)
sudo certbot certonly --manual --preferred-challenges dns \
-d "*.example.com" -d "example.com"
# Copy to certs folder
cp /etc/letsencrypt/live/example.com/fullchain.pem certs/inventarsystem.crt
cp /etc/letsencrypt/live/example.com/privkey.pem certs/inventarsystem.key
```
### 6. Deploy Multi-Tenant
```bash
# Install dependencies
pip install -r requirements.txt
# Start Multi-Tenant deployment
docker-compose -f docker-compose-multitenant.yml up -d
# Test
curl https://test.example.com/health
```
### 7. Verify (5 min)
```bash
# Check logs
docker-compose logs app
# Verify each tenant
curl https://schule1.example.com/debug/tenant
curl https://schule2.example.com/debug/tenant
# Cache stats
curl https://schule1.example.com/debug/cache-stats
```
## Rollback Plan
If something goes wrong:
1. Stop Multi-Tenant deployment
```bash
docker-compose -f docker-compose-multitenant.yml down
```
2. Restore from backup
```bash
cp .migration-backup-*/app.py Web/app.py
cp .migration-backup-*/settings.py Web/settings.py
```
3. Restart original deployment
```bash
docker-compose up -d
```
## Performance Expectations
- Memory per instance: 100-150MB (was 200MB)
- Response time: -30% (Redis caching)
- Max concurrent users: 3x increase
- Database load: -70% (query caching)
## Support
- Check logs: `docker-compose -f docker-compose-multitenant.yml logs -f app`
- Debug tenant: `curl https://your-tenant.com/debug/tenant`
- See MULTITENANT_DEPLOYMENT.md for detailed guide
EOF
log_success "Migration guide created at: $BACKUP_DIR/MIGRATION_NOTES.md"
}
validate_setup() {
log_info "Validating setup..."
# Check Python syntax of tenant modules
for module in tenant session_manager query_cache; do
module_file="$WEB_DIR/${module}.py"
if [[ -f "$module_file" ]]; then
if python3 -m py_compile "$module_file" 2>/dev/null; then
log_success "Module syntax valid: $module.py"
else
log_error "Syntax error in $module.py"
return 1
fi
fi
done
return 0
}
print_summary() {
cat << EOF
${GREEN}═══════════════════════════════════════════════════════════${NC}
${GREEN} Multi-Tenant Migration Summary${NC}
${GREEN}═══════════════════════════════════════════════════════════${NC}
${GREEN}✓ Prerequisites checked${NC}
${GREEN}✓ Backups created${NC}
${GREEN}✓ Tenant modules verified${NC}
${GREEN}✓ Docker files prepared${NC}
${GREEN}✓ app.py imports prepared${NC}
${GREEN}✓ requirements.txt updated${NC}
${YELLOW}Next Steps:${NC}
1. Review migration guide: $BACKUP_DIR/MIGRATION_NOTES.md
2. Manually update app.py (see guide above)
3. Setup DNS wildcard record
4. Create SSL certificate
5. Deploy: docker-compose -f docker-compose-multitenant.yml up -d
${BLUE}Documentation:${NC}
- MULTITENANT_DEPLOYMENT.md - Complete deployment guide
- MULTITENANT_INTEGRATION.md - Code integration examples
- $BACKUP_DIR/MIGRATION_NOTES.md - Step-by-step checklist
${GREEN}═══════════════════════════════════════════════════════════${NC}
EOF
}
main() {
log_info "Multi-Tenant Migration Starting..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_warning "Running in DRY-RUN mode - no changes will be made"
fi
check_prerequisites || exit 1
create_backups
check_tenant_modules || exit 1
check_docker_files
update_app_py || true
update_requirements
validate_setup || exit 1
generate_migration_guide
print_summary
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_warning "DRY-RUN completed - no changes were made"
log_info "Run without 'dry-run' parameter to execute migration"
fi
}
main "$@"
-119
View File
@@ -1,119 +0,0 @@
#!/bin/bash
# This script completely rebuilds the virtual environment
# It should be run as root/sudo
# Get the script directory
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
VENV_DIR="$SCRIPT_DIR/.venv"
BACKUP_DIR="$SCRIPT_DIR/.venv_backup_$(date +%Y%m%d%H%M%S)"
# Function to log messages
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log_message "Starting virtual environment rebuild"
# Backup existing virtual environment if it exists
if [ -d "$VENV_DIR" ]; then
log_message "Backing up existing virtual environment to $BACKUP_DIR"
mv "$VENV_DIR" "$BACKUP_DIR" || {
log_message "ERROR: Failed to backup existing virtual environment. Proceeding without backup."
rm -rf "$VENV_DIR"
}
fi
# Create logs directory with proper permissions
log_message "Ensuring logs directory exists with proper permissions"
mkdir -p "$SCRIPT_DIR/logs"
chmod 777 "$SCRIPT_DIR/logs" || log_message "WARNING: Could not set permissions on logs directory"
# Create a new virtual environment
log_message "Creating new virtual environment"
python3 -m venv "$VENV_DIR" || {
log_message "ERROR: Failed to create virtual environment. Trying with system packages..."
python3 -m venv "$VENV_DIR" --system-site-packages || {
log_message "ERROR: Failed to create virtual environment even with system packages. Exiting."
exit 1
}
}
# Set proper permissions
log_message "Setting permissions for new virtual environment"
chmod -R 755 "$VENV_DIR" || log_message "WARNING: Could not set permissions on virtual environment"
find "$VENV_DIR" -type d -exec chmod 755 {} \; 2>/dev/null
find "$VENV_DIR/bin" -type f -exec chmod +x {} \; 2>/dev/null
# Activate the virtual environment
log_message "Activating virtual environment"
source "$VENV_DIR/bin/activate" || {
log_message "ERROR: Failed to activate virtual environment"
exit 1
}
# Install packages
log_message "Upgrading pip"
pip install --upgrade pip || log_message "WARNING: Failed to upgrade pip"
log_message "Installing pymongo"
# First ensure bson is removed to avoid conflicts
pip uninstall -y bson || log_message "WARNING: Failed to uninstall bson (may not exist)"
# Check if bson directory exists after uninstall and remove it if necessary
if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then
log_message "Force removing bson directory"
rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson"
fi
# Now install pymongo
pip install pymongo==4.6.3 || log_message "WARNING: Failed to install pymongo"
# Verify pymongo installation
python -c "import pymongo; print(f'PyMongo version: {pymongo.__version__}')" && log_message "✓ PyMongo installed correctly" || log_message "WARNING: PyMongo verification failed"
# Install other requirements if they exist
if [ -f "$SCRIPT_DIR/requirements.txt" ]; then
log_message "Installing packages from requirements.txt (excluding bson)"
# Create a modified requirements file without bson
grep -v "^bson" "$SCRIPT_DIR/requirements.txt" > "$SCRIPT_DIR/requirements_filtered.txt"
# Also remove pymongo since we already installed it
grep -v "^pymongo" "$SCRIPT_DIR/requirements_filtered.txt" > "$SCRIPT_DIR/requirements_no_conflicts.txt"
pip install -r "$SCRIPT_DIR/requirements_no_conflicts.txt" || log_message "WARNING: Failed to install some packages from requirements.txt"
rm -f "$SCRIPT_DIR/requirements_filtered.txt" "$SCRIPT_DIR/requirements_no_conflicts.txt"
log_message "Verifying pymongo installation after package installation..."
# Check if the bson directory from the standalone package still exists and remove it
if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then
log_message "Found standalone bson package, removing it..."
rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson"
# Reinstall pymongo to ensure it's correctly configured
pip install --force-reinstall pymongo==4.6.3
fi
else
log_message "No requirements.txt found, installing essential packages manually"
pip install flask werkzeug gunicorn pillow qrcode apscheduler python-dateutil pytz requests || {
log_message "WARNING: Failed to install some essential packages"
}
fi
# Deactivate virtual environment
deactivate
# Verify the virtual environment works correctly
log_message "Verifying virtual environment..."
if "$VENV_DIR/bin/python" -c "import pymongo, flask, gunicorn, requests, PIL, apscheduler; exit(0)" 2>/dev/null; then
log_message "✓ Virtual environment verification successful"
# Clean up backup if everything works
if [ -d "$BACKUP_DIR" ]; then
log_message "Removing backup virtual environment since verification was successful"
rm -rf "$BACKUP_DIR"
log_message "✓ Backup environment removed"
fi
else
log_message "WARNING: Virtual environment verification failed"
log_message "Keeping backup at $BACKUP_DIR in case it's needed for recovery"
fi
log_message "Virtual environment rebuild complete"
log_message "You can now run the restart.sh script"
exit 0
+2 -1
View File
@@ -12,5 +12,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
+104 -4
View File
@@ -1,7 +1,107 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$SCRIPT_DIR"
"$SCRIPT_DIR/stop.sh"
"$SCRIPT_DIR/start.sh"
COMPOSE_FILE="docker-compose-multitenant.yml"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
write_runtime_override() {
local current_ports=""
local app_image=""
local ports=()
if [ -f "$ENV_FILE" ]; then
app_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
fi
fi
if [ -n "$app_image" ]; then
if [ -n "$current_ports" ]; then
mapfile -t ports < <(parse_port_list "$current_ports")
fi
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
image: $app_image
build: null
EOF
if [ ${#ports[@]} -gt 0 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports[@]}"; do
if [ -n "$port" ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
fi
done
fi
else
rm -f "$RUNTIME_COMPOSE_OVERRIDE_FILE"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Rebuilding and/or restarting app container using $COMPOSE_FILE..."
write_runtime_override
compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=( -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" )
fi
if [ -f "$ENV_FILE" ]; then
compose_args+=( --env-file "$ENV_FILE" )
fi
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
docker compose "${compose_args[@]}" up -d --build --force-recreate app
else
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
if ! docker compose "${compose_args[@]}" up -d --no-build --force-recreate app; then
echo "Warning: app image not found or not available locally. Attempting docker compose pull app..."
docker compose "${compose_args[@]}" pull app
docker compose "${compose_args[@]}" up -d --no-build --force-recreate app
fi
fi
echo "Cleaning up unused Docker images..."
docker image prune -f
echo "App restart complete."
-527
View File
@@ -1,527 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
SOURCE_PATH=""
BACKUP_DATE=""
DROP_DATABASE=false
RESTART_SERVICES=false
STAGED_PATH=""
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
DOCKER_COMPOSE=()
cleanup() {
rm -rf "$WORK_DIR" >/dev/null 2>&1 || true
}
trap cleanup EXIT
mkdir -p "$LOG_DIR"
log() {
local msg="$1"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $msg" | tee -a "$LOG_FILE"
}
show_help() {
cat <<EOF
Inventarsystem Restore / Porting Tool (Docker-first)
Usage:
$0 --source <path> [options]
$0 --date <YYYY-MM-DD|latest> [options]
$0 --list
Options:
--source <path> Backup source path.
Supported:
- old folder with CSV files
- old Inventarsystem-*.tar.gz backup
- new mongodb-*.archive.gz backup
- folder containing mongodb-*.archive.gz
--date <value> Resolve source from known backup locations.
- YYYY-MM-DD: checks backups/YYYY-MM-DD and /var/backups/Inventarsystem-YYYY-MM-DD(.tar.gz)
- latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore
--list List detected backup candidates
--help Show this help
Notes:
- Always restores into current DB name: $DB_NAME
- Supports both legacy CSV and new archive backups
- Normalizes collection names to current structure (users, items, ausleihungen, filter_presets, settings)
EOF
}
setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose)
return 0
fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose)
return 0
fi
log "ERROR: docker compose is not available"
exit 1
}
compose() {
"${DOCKER_COMPOSE[@]}" "$@"
}
list_backups() {
echo "Detected backup candidates:"
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
}
resolve_latest_source() {
local latest
latest="$({
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
} | sort -nr | head -n1 | cut -d' ' -f2-)"
if [ -z "$latest" ]; then
log "ERROR: no backup candidates found"
exit 1
fi
SOURCE_PATH="$latest"
}
resolve_date_source() {
local d="$1"
if [ "$d" = "latest" ]; then
resolve_latest_source
return
fi
local p1="$BACKUP_ROOT_LOCAL/$d"
local p2="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d"
local p3="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d.tar.gz"
if [ -e "$p1" ]; then
SOURCE_PATH="$p1"
elif [ -e "$p2" ]; then
SOURCE_PATH="$p2"
elif [ -e "$p3" ]; then
SOURCE_PATH="$p3"
else
log "ERROR: no backup found for date $d"
exit 1
fi
}
ensure_stack() {
log "Ensuring mongodb/app services are running..."
compose up -d mongodb app >/dev/null
if ! compose ps --status running mongodb | grep -q mongodb; then
log "ERROR: mongodb container is not running"
exit 1
fi
if ! compose ps --status running app | grep -q app; then
log "ERROR: app container is not running"
exit 1
fi
}
stage_source() {
local src="$1"
if [ ! -e "$src" ]; then
log "ERROR: source path does not exist: $src"
exit 1
fi
if [ -f "$src" ] && [[ "$src" == *.tar.gz ]]; then
log "Extracting tar backup: $src"
tar -xzf "$src" -C "$WORK_DIR"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -f "$src" ]; then
cp -f "$src" "$WORK_DIR/"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -d "$src" ]; then
STAGED_PATH="$src"
return
fi
log "ERROR: unsupported source type: $src"
exit 1
}
find_archive_file() {
local root="$1"
find "$root" -type f \( -name "*.archive.gz" -o -name "*.archive" \) 2>/dev/null | head -n1 || true
}
find_best_csv_dir() {
local root="$1"
local best_dir=""
local best_count=0
while IFS= read -r dir; do
[ -z "$dir" ] && continue
local count
count="$(find "$dir" -maxdepth 1 -type f -name '*.csv' | wc -l | tr -d ' ')"
if [ "$count" -gt "$best_count" ]; then
best_count="$count"
best_dir="$dir"
fi
done < <(find "$root" -type f -name '*.csv' -printf '%h\n' 2>/dev/null | sort -u)
if [ "$best_count" -gt 0 ]; then
echo "$best_dir"
fi
}
normalize_structure() {
local py="$WORK_DIR/normalize_db.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
rename_map = {
"user": "users",
"item": "items",
"ausleihung": "ausleihungen",
"filter_preset": "filter_presets",
"preset": "filter_presets",
"setting": "settings",
}
for old, new in rename_map.items():
if old not in db.list_collection_names():
continue
if new not in db.list_collection_names():
db[old].rename(new)
print(f"Renamed collection {old} -> {new}")
continue
moved = 0
for doc in db[old].find({}):
db[new].insert_one(doc)
moved += 1
db[old].drop()
print(f"Merged {moved} docs from {old} into {new}")
print("Final collections:", sorted(db.list_collection_names()))
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/normalize_db.py"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/normalize_db.py
}
import_archive() {
local archive_file="$1"
local mongo_cid
mongo_cid="$(compose ps -q mongodb)"
if [ -z "$mongo_cid" ]; then
log "ERROR: could not resolve mongodb container id"
exit 1
fi
log "Importing archive backup: $archive_file"
$SUDO docker cp "$archive_file" "$mongo_cid:/tmp/restore.archive.gz"
local drop_flag=""
if [ "$DROP_DATABASE" = true ]; then
drop_flag="--drop"
fi
compose exec -T mongodb sh -lc "mongorestore --archive=/tmp/restore.archive.gz --gzip $drop_flag"
compose exec -T mongodb rm -f /tmp/restore.archive.gz >/dev/null 2>&1 || true
normalize_structure
}
import_csv() {
local csv_dir="$1"
local app_cid py
app_cid="$(compose ps -q app)"
if [ -z "$app_cid" ]; then
log "ERROR: could not resolve app container id"
exit 1
fi
log "Importing CSV backup directory: $csv_dir"
compose exec -T app sh -lc "rm -rf /tmp/restore_csv && mkdir -p /tmp/restore_csv"
$SUDO docker cp "$csv_dir/." "$app_cid:/tmp/restore_csv"
py="$WORK_DIR/import_csv.py"
cat > "$py" <<'PY'
import os
import csv
import ast
import re
from pymongo import MongoClient
from bson.objectid import ObjectId
DB_NAME = os.getenv("DB_NAME", "Inventarsystem")
CSV_DIR = os.getenv("CSV_DIR", "/tmp/restore_csv")
DROP_DATABASE = os.getenv("DROP_DATABASE", "false").lower() == "true"
HOST = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
PORT = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
COLLECTION_MAP = {
"users": "users",
"user": "users",
"items": "items",
"item": "items",
"ausleihungen": "ausleihungen",
"ausleihung": "ausleihungen",
"filter_presets": "filter_presets",
"filter_preset": "filter_presets",
"settings": "settings",
"setting": "settings",
}
BOOL_FIELDS = {"Admin", "Verfuegbar", "enabled"}
INT_FIELDS = {"Anschaffungskosten", "filter_num"}
OID_LIKE_FIELDS = {"_id", "Item"}
client = MongoClient(HOST, PORT)
db = client[DB_NAME]
if DROP_DATABASE:
client.drop_database(DB_NAME)
db = client[DB_NAME]
def parse_scalar(key, value):
if value is None:
return None
value = value.strip()
if value == "":
return None
if key in BOOL_FIELDS:
lowered = value.lower()
if lowered == "true":
return True
if lowered == "false":
return False
if key in INT_FIELDS:
try:
return int(value)
except Exception:
pass
if key in OID_LIKE_FIELDS and re.fullmatch(r"[0-9a-fA-F]{24}", value):
return ObjectId(value)
if value.startswith("[") and value.endswith("]"):
try:
return ast.literal_eval(value)
except Exception:
pass
return value
def load_csv_file(csv_path):
docs = []
with open(csv_path, "r", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
doc = {}
for key, value in row.items():
doc[key] = parse_scalar(key, value)
docs.append(doc)
return docs
csv_files = [
os.path.join(CSV_DIR, f)
for f in sorted(os.listdir(CSV_DIR))
if f.endswith(".csv")
]
if not csv_files:
raise SystemExit(f"No CSV files found in {CSV_DIR}")
for csv_file in csv_files:
base = os.path.splitext(os.path.basename(csv_file))[0]
coll = COLLECTION_MAP.get(base, base)
docs = load_csv_file(csv_file)
db[coll].drop()
if docs:
db[coll].insert_many(docs)
print(f"Imported {len(docs)} docs into {coll} from {os.path.basename(csv_file)}")
print("Collections after CSV import:", sorted(db.list_collection_names()))
PY
$SUDO docker cp "$py" "$app_cid:/tmp/import_csv.py"
compose exec -T app env DB_NAME="$DB_NAME" CSV_DIR="/tmp/restore_csv" DROP_DATABASE="$DROP_DATABASE" python /tmp/import_csv.py
normalize_structure
}
print_counts() {
local py="$WORK_DIR/print_counts.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
for name in ["users", "items", "ausleihungen", "filter_presets", "settings"]:
try:
count = db[name].count_documents({})
except Exception:
count = -1
print(f"{name}: {count}")
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/print_counts.py"
log "Post-restore collection counts:"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/print_counts.py | tee -a "$LOG_FILE"
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--source)
SOURCE_PATH="${2:-}"
shift 2
;;
--date)
BACKUP_DATE="${2:-}"
shift 2
;;
--drop-database)
DROP_DATABASE=true
shift
;;
--restart-services)
RESTART_SERVICES=true
shift
;;
--list)
list_backups
exit 0
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown argument: $1"
show_help
exit 1
;;
esac
done
}
main() {
parse_args "$@"
setup_compose
if [ -n "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
log "ERROR: use either --source or --date, not both"
exit 1
fi
if [ -z "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
resolve_date_source "$BACKUP_DATE"
fi
if [ -z "$SOURCE_PATH" ]; then
log "ERROR: provide --source <path> or --date <YYYY-MM-DD|latest>"
show_help
exit 1
fi
ensure_stack
local staged archive_file csv_dir
stage_source "$SOURCE_PATH"
staged="$STAGED_PATH"
archive_file="$(find_archive_file "$staged")"
if [ -n "$archive_file" ]; then
import_archive "$archive_file"
print_counts
else
csv_dir="$(find_best_csv_dir "$staged")"
if [ -z "$csv_dir" ]; then
log "ERROR: no supported backup data found under: $SOURCE_PATH"
log "Expected either *.archive.gz or CSV files"
exit 1
fi
import_csv "$csv_dir"
print_counts
fi
if [ "$RESTART_SERVICES" = true ]; then
log "Restarting services..."
"$SCRIPT_DIR/restart.sh"
fi
log "Restore completed successfully into DB: $DB_NAME"
}
main "$@"
Executable → Regular
+4 -47
View File
@@ -1,47 +1,4 @@
#!/bin/bash
# Helper script to run the backup with proper Python environment
# Get the script directory
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# Default values if no arguments provided
if [ $# -eq 0 ]; then
# Define default values
DB="Inventarsystem"
URI="mongodb://localhost:27017/"
OUT="$SCRIPT_DIR/backups/$(date +%Y-%m-%d)"
echo "No arguments provided. Using defaults:"
echo " --uri $URI"
echo " --db $DB"
echo " --out $OUT"
# Create output directory if it doesn't exist
mkdir -p "$OUT"
# Set the arguments
ARGS="--uri $URI --db $DB --out $OUT"
else
# Use provided arguments
ARGS="$@"
fi
# Check if we're in a virtual environment
if [ -f "$SCRIPT_DIR/.venv/bin/python" ]; then
# Check if we have execute permissions for the virtual environment Python
if [ -x "$SCRIPT_DIR/.venv/bin/python" ]; then
# Use the virtual environment's Python
"$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/Backup-DB.py" $ARGS || {
echo "Failed to execute with virtual environment Python. Trying system Python..."
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
}
else
echo "Virtual environment found but Python is not executable. Using system Python instead."
echo "To fix virtual environment permissions, run: sudo ./rebuild-venv.sh"
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
fi
else
# Use system Python
echo "No virtual environment found. Using system Python."
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
fi
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/backup.sh" "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
# Wrapper to run tenant management fully containerized via docker-compose
docker compose -f docker-compose-multitenant.yml --profile tools run --rm tenant-manager "$@"
+250 -184
View File
@@ -7,17 +7,27 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="80"
HTTPS_PORT_VALUE="443"
HTTP_PORT_VALUE="10000"
HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
COMPOSE_FILE="docker-compose-multitenant.yml"
COMPOSE_PROFILES_VALUE=""
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
usage() {
cat <<EOF
@@ -26,6 +36,7 @@ Usage: $0 [options]
Options:
--no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default)
--multitenant Use the multi-tenant architecture deployment
-h, --help Show this help message
EOF
}
@@ -41,6 +52,10 @@ parse_args() {
CRON_SETUP_VALUE="1"
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
-h|--help)
usage
exit 0
@@ -100,7 +115,11 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
install_docker_engine
if [ "$IS_ROOT" = "true" ]; then
install_docker_engine
else
missing+=(docker)
fi
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -124,14 +143,20 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
else
echo "ERROR: Missing dependencies: ${missing[*]}"
echo "Please install the missing tools or run this script as root."
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
if cron_setup_enabled; then
$SUDO systemctl enable --now cron >/dev/null 2>&1 || true
systemctl enable --now cron >/dev/null 2>&1 || true
fi
fi
}
@@ -144,6 +169,11 @@ setup_boot_autostart_service() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping systemd autostart setup when not running as root."
return 0
fi
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
@@ -185,6 +215,11 @@ setup_scheduled_jobs() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping cron job setup when not running as root."
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
echo "Warning: crontab not available, skipping nightly update setup"
return 0
@@ -195,101 +230,17 @@ setup_scheduled_jobs() {
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
local existing_cron
if [ "$(id -u)" -eq 0 ]; then
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
else
existing_cron="$($SUDO crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | $SUDO crontab -
fi
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
echo "Nightly backup scheduled at 02:30"
echo "Nightly auto-update scheduled at 03:00"
}
ensure_tls_certificates() {
local cert_dir cert_path key_path cn
cert_dir="$SCRIPT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
mkdir -p "$cert_dir"
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
return 0
fi
cn="${TLS_CN:-localhost}"
echo "No TLS certificates found. Generating self-signed certificate for CN=$cn"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
chmod 600 "$key_path"
chmod 644 "$cert_path"
}
ensure_nginx_config_mount_source() {
local nginx_dir config_path backup_path
nginx_dir="$SCRIPT_DIR/docker/nginx"
config_path="$nginx_dir/default.conf"
mkdir -p "$nginx_dir"
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'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
client_max_body_size 50M;
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
echo "Recreated missing nginx config at $config_path"
fi
}
ensure_runtime_config_json() {
local config_path backup_path
config_path="$SCRIPT_DIR/config.json"
@@ -306,7 +257,7 @@ ensure_runtime_config_json() {
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "mongodb",
"port": 27017,
@@ -445,112 +396,214 @@ find_free_port() {
echo "$port"
}
stack_owns_host_port() {
local requested_port="$1"
local container_port="$2"
local mapped_port
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
if [ -z "$mapped_port" ]; then
return 1
fi
mapped_port="${mapped_port##*:}"
[ "$mapped_port" = "$requested_port" ]
}
stop_host_nginx_services() {
local stopped_any=false
local service_name
if ! command -v systemctl >/dev/null 2>&1; then
return 1
fi
while IFS= read -r service_name; do
[ -z "$service_name" ] && continue
echo "Stopping host service $service_name to free web ports..."
$SUDO systemctl stop "$service_name" >/dev/null 2>&1 || true
stopped_any=true
done < <(systemctl list-units --type=service --state=active --no-pager 2>/dev/null | awk '{print $1}' | grep -E '(^nginx\.service$|nginx)' || true)
if [ "$stopped_any" = true ]; then
sleep 2
fi
if [ "$stopped_any" = true ]; then
return 0
fi
return 1
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
configure_host_ports() {
local requested_http requested_https
local requested_http
local requested_ports
local ports=()
local occupied_ports=()
requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if [ -z "$requested_http" ]; then
requested_http="80"
requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if stack_owns_host_port "$requested_http" "80"; then
HTTP_PORT_VALUE="$requested_http"
elif port_in_use "$requested_http"; then
stop_host_nginx_services || true
if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
requested_ports="$INVENTAR_HTTP_PORTS"
fi
if ! port_in_use "$requested_http"; then
HTTP_PORT_VALUE="$requested_http"
echo "Freed HTTP port $requested_http by stopping host nginx service"
else
HTTP_PORT_VALUE="$(find_free_port 8080)"
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
requested_ports="$INVENTAR_HTTP_PORT"
fi
if [ -n "$requested_ports" ]; then
mapfile -t ports < <(parse_port_list "$requested_ports")
fi
if [ ${#ports[@]} -gt 0 ]; then
HTTP_PORTS_VALUE="${ports[*]}"
HTTP_PORT_VALUE="${ports[0]}"
for port in "${ports[@]}"; do
if port_in_use "$port"; then
occupied_ports+=("$port")
fi
done
if [ ${#occupied_ports[@]} -gt 0 ]; then
if [ ${#ports[@]} -eq 1 ]; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port ${ports[0]} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
else
echo "Error: requested host port(s) already in use: ${occupied_ports[*]}"
echo "Remove the occupied port(s) from INVENTAR_HTTP_PORTS or stop the conflicting service."
exit 1
fi
fi
else
HTTP_PORT_VALUE="$requested_http"
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
if port_in_use "$HTTP_PORT_VALUE"; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
fi
}
ensure_min_docker_disk_space() {
local docker_root available_kb available_mb
if ! command -v df >/dev/null 2>&1; then
return 0
fi
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
if [ -z "$docker_root" ]; then
docker_root="/var/lib/docker"
fi
if [ ! -d "$docker_root" ]; then
return 0
fi
available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
if [ -z "$available_kb" ]; then
return 0
fi
available_mb=$((available_kb / 1024))
if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
echo "Error: low disk space in Docker data root ($docker_root)."
echo "Available: ${available_mb} MB; required minimum: ${MIN_DOCKER_FREE_MB} MB"
echo "MongoDB may fail with 'No space left on device'. Free space and retry."
exit 1
fi
}
detect_server_capacity() {
local cpus mem_kb mem_gb app_workers app_cpus app_mem app_mem_swap
cpus="$(nproc 2>/dev/null || echo 1)"
mem_kb="$(awk '/MemAvailable:/ {print $2; exit}' /proc/meminfo 2>/dev/null || awk '/MemTotal:/ {print $2; exit}' /proc/meminfo || echo 0)"
mem_gb=$(( (mem_kb + 1024*1024 - 1) / (1024*1024) ))
if [ "$cpus" -ge 8 ] && [ "$mem_gb" -ge 16 ]; then
app_workers=8
app_cpus="2.0"
app_mem="512m"
app_mem_swap="1024m"
elif [ "$cpus" -ge 4 ] && [ "$mem_gb" -ge 8 ]; then
app_workers=4
app_cpus="1.0"
app_mem="384m"
app_mem_swap="768m"
elif [ "$cpus" -ge 2 ] && [ "$mem_gb" -ge 4 ]; then
app_workers=2
app_cpus="0.75"
app_mem="320m"
app_mem_swap="640m"
else
app_workers=1
app_cpus="0.5"
app_mem="256m"
app_mem_swap="512m"
fi
requested_https=""
if [ -f "$ENV_FILE" ]; then
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
local env_app_cpus env_app_mem env_app_mem_swap env_workers
env_app_cpus="$(awk -F= '/^INVENTAR_APP_CPUS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem="$(awk -F= '/^INVENTAR_APP_MEM_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem_swap="$(awk -F= '/^INVENTAR_APP_MEM_SWAP_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_workers="$(awk -F= '/^INVENTAR_WORKERS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
[ -n "$env_app_cpus" ] && app_cpus="$env_app_cpus"
[ -n "$env_app_mem" ] && app_mem="$env_app_mem"
[ -n "$env_app_mem_swap" ] && app_mem_swap="$env_app_mem_swap"
[ -n "$env_workers" ] && app_workers="$env_workers"
fi
if [ -z "$requested_https" ]; then
requested_https="443"
fi
INVENTAR_APP_CPUS="${INVENTAR_APP_CPUS:-$app_cpus}"
INVENTAR_APP_MEM_LIMIT="${INVENTAR_APP_MEM_LIMIT:-$app_mem}"
INVENTAR_APP_MEM_SWAP_LIMIT="${INVENTAR_APP_MEM_SWAP_LIMIT:-$app_mem_swap}"
INVENTAR_WORKERS="${INVENTAR_WORKERS:-$app_workers}"
INVENTAR_THREADS="${INVENTAR_THREADS:-2}"
if stack_owns_host_port "$requested_https" "443"; then
HTTPS_PORT_VALUE="$requested_https"
elif port_in_use "$requested_https"; then
stop_host_nginx_services || true
if ! port_in_use "$requested_https"; then
HTTPS_PORT_VALUE="$requested_https"
echo "Freed HTTPS port $requested_https by stopping host nginx service"
return
fi
HTTPS_PORT_VALUE="$(find_free_port 8443)"
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else
HTTPS_PORT_VALUE="$requested_https"
fi
echo "Detected host capacity: CPUs=$cpus, RAM=${mem_gb}GB"
echo "Configured app runtime: INVENTAR_WORKERS=$INVENTAR_WORKERS, INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS, INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT"
}
write_env_file() {
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
INVENTAR_HTTPS_PORT=$HTTPS_PORT_VALUE
INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS
INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT
INVENTAR_APP_MEM_SWAP_LIMIT=$INVENTAR_APP_MEM_SWAP_LIMIT
INVENTAR_WORKERS=$INVENTAR_WORKERS
INVENTAR_THREADS=$INVENTAR_THREADS
INVENTAR_WORKER_TIMEOUT=${INVENTAR_WORKER_TIMEOUT:-30}
INVENTAR_WORKER_CONNECTIONS=${INVENTAR_WORKER_CONNECTIONS:-100}
EOF
}
write_runtime_compose_override() {
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ${APP_IMAGE_VALUE}
build: null
EOF
if [ -n "$HTTP_PORTS_VALUE" ]; then
local ports_array
local ports_list
ports_list="${HTTP_PORTS_VALUE//,/ }"
read -r -a ports_array <<<"$ports_list"
if [ "${#ports_array[@]}" -gt 0 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports_array[@]}"; do
if [ -n "$port" ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
fi
done
fi
fi
}
verify_stack_health() {
local compose_args running_services retry_count=0
compose_args=(--env-file "$ENV_FILE")
compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_args+=(--env-file "$ENV_FILE")
# Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do
@@ -558,10 +611,10 @@ verify_stack_health() {
for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \
printf '%s\n' "$running_services" | grep -Fxq nginx && \
printf '%s\n' "$running_services" | grep -Fxq redis && \
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then
if curl -kfsS "https://127.0.0.1:$HTTPS_PORT_VALUE" >/dev/null 2>&1; then
if curl -fsS "http://127.0.0.1:$HTTP_PORT_VALUE/health" >/dev/null 2>&1; then
echo "Health check passed."
return 0
fi
@@ -574,8 +627,8 @@ verify_stack_health() {
if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
docker compose "${compose_args[@]}" restart app nginx mongodb
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
docker compose "${compose_args[@]}" restart app redis mongodb
sleep 3
((retry_count++))
else
@@ -586,7 +639,7 @@ verify_stack_health() {
# Final failure
echo "Error: stack health check failed after restart attempt."
docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
return 1
}
@@ -594,20 +647,33 @@ parse_args "$@"
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
configure_host_ports
ensure_min_docker_disk_space
detect_server_capacity
ensure_app_image_loaded
write_env_file
write_runtime_compose_override
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
docker compose --env-file "$ENV_FILE" up -d --remove-orphans
compose_up_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_up_args+=(--env-file "$ENV_FILE")
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
fi
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
sleep 5
docker compose "${compose_up_args[@]}" up -d --remove-orphans
fi
verify_stack_health
echo "Stack started."
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE"
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+15 -1
View File
@@ -4,12 +4,26 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose-multitenant.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Stopping Inventarsystem Docker stack..."
docker compose down
docker compose -f "$COMPOSE_FILE" down
echo "Stack stopped."
+157 -82
View File
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$PROJECT_DIR/dist"
COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true
@@ -35,6 +38,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
apt_install() {
$SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
@@ -44,7 +52,7 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
missing+=(docker.io)
missing+=(docker)
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -64,88 +72,108 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
else
log_message "ERROR: Missing dependencies: ${missing[*]}"
log_message "Install the missing tools manually or re-run as root."
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
fi
}
ensure_tls_certificates() {
local cert_dir cert_path key_path cn
cert_dir="$PROJECT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
ensure_min_root_disk_space() {
local available_kb available_mb
mkdir -p "$cert_dir"
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
if ! command -v df >/dev/null 2>&1; then
return 0
fi
cn="${TLS_CN:-localhost}"
log_message "No TLS certificates found. Generating self-signed certificate for CN=$cn"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
chmod 600 "$key_path"
chmod 644 "$cert_path"
}
ensure_nginx_config_mount_source() {
local nginx_dir config_path backup_path
nginx_dir="$PROJECT_DIR/docker/nginx"
config_path="$nginx_dir/default.conf"
mkdir -p "$nginx_dir"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
log_message "WARNING: Moved unexpected directory $config_path to $backup_path"
available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
if [ -z "$available_kb" ]; then
return 0
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
available_mb=$((available_kb / 1024))
if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
log_message "Free disk space and rerun update."
exit 1
fi
}
server {
listen 443 ssl;
server_name _;
cleanup_old_dist_artifacts() {
local keep_count
keep_count="$DIST_KEEP_COUNT"
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
if [ ! -d "$DIST_DIR" ]; then
return 0
fi
client_max_body_size 50M;
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
keep_count=2
fi
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
mapfile -t archives < <(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) -printf '%T@ %p\n' | sort -nr | awk '{print $2}')
if [ "${#archives[@]}" -le "$keep_count" ]; then
return 0
fi
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
local index old_archive deleted=0
for (( index=keep_count; index<${#archives[@]}; index++ )); do
old_archive="${archives[$index]}"
if rm -f "$old_archive"; then
deleted=$((deleted + 1))
fi
done
if [ "$deleted" -gt 0 ]; then
log_message "Cleaned up $deleted old dist image archive(s)"
fi
}
cleanup_docker_dangling_images() {
if docker image prune -f >> "$LOG_FILE" 2>&1; then
log_message "Cleaned up dangling Docker images"
else
log_message "WARNING: Could not prune dangling Docker images"
fi
}
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--multitenant Use docker-compose-multitenant.yml (default)
-h, --help Show this help message
EOF
log_message "Recreated missing nginx config at $config_path"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
-h|--help)
usage
exit 0
;;
*)
log_message "ERROR: Unknown option: $1"
usage
exit 2
;;
esac
done
}
archive_logs() {
@@ -311,9 +339,7 @@ download_and_extract_bundle() {
tar -xzf "$archive" -C "$tmp_dir"
# The bundle must contain docker deployment files only.
mkdir -p "$PROJECT_DIR/docker/nginx"
cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml"
cp -f "$tmp_dir/docker/nginx/default.conf" "$PROJECT_DIR/docker/nginx/default.conf"
cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh"
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
@@ -325,25 +351,51 @@ download_and_extract_bundle() {
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
fi
# Neu: Multi-Tenant Ressourcen kopieren, falls vorhanden
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
fi
if [ -f "$tmp_dir/manage-tenant.sh" ]; then
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
fi
if [ -f "$tmp_dir/run-tenant-cmd.sh" ]; then
cp -f "$tmp_dir/run-tenant-cmd.sh" "$PROJECT_DIR/run-tenant-cmd.sh"
fi
for file in "MULTITENANT_DEPLOYMENT.md" "MULTITENANT_PYTHON_API.md"; do
if [ -f "$tmp_dir/$file" ]; then
cp -f "$tmp_dir/$file" "$PROJECT_DIR/$file"
fi
done
# Ensure executable permissions on all copied scripts
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
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"
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
}
deploy() {
local tag="$1"
local meta_file="$2"
local app_image="${APP_IMAGE_REPO}:${tag}"
local compose_path
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
log_message "ERROR: compose file not found: $compose_path"
exit 1
fi
cd "$PROJECT_DIR"
if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_HTTP_PORT=10000
INVENTAR_APP_IMAGE=$app_image
EOF
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
@@ -362,27 +414,27 @@ EOF
fi
fi
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 compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1
docker compose -f "$compose_path" --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() {
local compose_args running_services
local https_port
compose_args=(--env-file "$ENV_FILE")
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$https_port" ]; then
https_port="443"
local http_port
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$http_port" ]; then
http_port="10000"
fi
for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \
printf '%s\n' "$running_services" | grep -Fxq nginx && \
printf '%s\n' "$running_services" | grep -Fxq redis && \
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
# Primary check: HTTP endpoint responds (most reliable)
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then
# Primary check: health endpoint responds (most reliable)
if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
return 0
fi
fi
@@ -390,20 +442,41 @@ verify_stack_health() {
done
docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb >> "$LOG_FILE" 2>&1 || true
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb >> "$LOG_FILE" 2>&1 || true
return 1
}
cleanup_server_space() {
log_message "Running server cleanup before update..."
# Remove unused Docker objects
if docker system prune -af --volumes >> "$LOG_FILE" 2>&1; then
log_message "Docker system pruned (all unused images, containers, volumes, networks)"
else
log_message "WARNING: Docker system prune failed"
fi
# Clean up old dist artifacts
cleanup_old_dist_artifacts
# Clean up log files older than 7 days
if find "$LOG_DIR" -type f -name '*.log' -mtime +7 -exec rm -f {} +; then
log_message "Old log files (older than 7 days) cleaned up"
else
log_message "WARNING: Failed to clean up old log files"
fi
}
main() {
parse_args "$@"
cleanup_server_space
ensure_runtime_dependencies
ensure_tls_certificates
ensure_nginx_config_mount_source
require_cmd curl
require_cmd tar
require_cmd docker
require_cmd python3
ensure_min_root_disk_space
archive_logs
create_backup
@@ -491,6 +564,8 @@ main() {
fi
echo "$latest_tag" > "$STATE_FILE"
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag"
}