Compare commits

...

54 Commits

Author SHA1 Message Date
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
54 changed files with 2013 additions and 973 deletions
+2 -3
View File
@@ -1,4 +1,3 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443 INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
+6
View File
@@ -0,0 +1,6 @@
services:
app:
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", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
build: null
+41 -32
View File
@@ -158,42 +158,17 @@ jobs:
- name: Create release-only docker bundle - name: Create release-only docker bundle
run: | run: |
mkdir -p release-bundle mkdir -p release-bundle
mkdir -p release-bundle/docker/nginx
cat > release-bundle/docker-compose.yml <<EOF cat > release-bundle/docker-compose.yml <<EOF
services: 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: app:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest} image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
pull_policy: never
container_name: inventarsystem-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
depends_on: depends_on:
mongodb: - mongodb
condition: service_healthy - redis
environment: environment:
INVENTAR_MONGODB_HOST: mongodb INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017" INVENTAR_MONGODB_PORT: "27017"
@@ -211,6 +186,33 @@ jobs:
- app_backups:/data/backups - app_backups:/data/backups
- app_logs:/data/logs - 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: volumes:
mongodb_data: mongodb_data:
app_uploads: app_uploads:
@@ -219,9 +221,9 @@ jobs:
app_qrcodes: app_qrcodes:
app_backups: app_backups:
app_logs: app_logs:
redis_data:
EOF EOF
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
cp start.sh release-bundle/start.sh cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh cp restart.sh release-bundle/restart.sh
@@ -229,8 +231,15 @@ jobs:
cp config.json release-bundle/config.json cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh cp update.sh release-bundle/update.sh
cp init-admin.sh release-bundle/init-admin.sh cp init-admin.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/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 release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle . tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release - 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...`
+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 │ │ schule1 │ │ schule2 │ │ schule3 │
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │ │ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
│ 20 Users │ │ 20 Users │ │ 20 Users │ │ 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+ **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 ## Support & Debugging
**Fragen?** **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.
+69 -4
View File
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
return None 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(): def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state.""" """Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip() env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
@@ -1291,9 +1313,17 @@ def _initialize_scheduler():
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path) os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)") app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception: except Exception as e:
pass # If we can't clean up, continue anyway 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:
# Try to create the lock file - only succeeds if it doesn't exist # 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) lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
@@ -1302,7 +1332,7 @@ def _initialize_scheduler():
except FileExistsError: except FileExistsError:
should_start = False should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler") app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start: if should_start:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS) 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) scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
@@ -2063,6 +2093,11 @@ def _upload_excel_items(scope='inventory'):
validation_warnings = [] validation_warnings = []
reserved_codes = set() reserved_codes = set()
known_locations = set(it.get_predefined_locations()) 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): def is_code_available(candidate_code):
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code) return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
@@ -2114,8 +2149,19 @@ def _upload_excel_items(scope='inventory'):
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt')) validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1) 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) 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')) 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')) anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
raw_year = val('anschaffungsjahr') raw_year = val('anschaffungsjahr')
@@ -2217,6 +2263,21 @@ def _upload_excel_items(scope='inventory'):
it.add_predefined_location(row['ort']) it.add_predefined_location(row['ort'])
known_locations.add(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 series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
row_created_ids = [] row_created_ids = []
@@ -10753,6 +10814,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
# PUSH NOTIFICATION API ENDPOINTS # PUSH NOTIFICATION API ENDPOINTS
# ============================================================================ # ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST']) @app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push(): def subscribe_to_push():
""" """
-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
+2 -1
View File
@@ -13,5 +13,6 @@ redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl
cryptography cryptography>=42.0.0
pywebpush pywebpush
py-vapid>=1.9.0
+30 -2
View File
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods']) SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
# Optional feature modules # Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled'])) TENANT_CONFIGS = _get(_conf, ['tenants'], {})
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
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})"
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_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'])) STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
+78 -19
View File
@@ -21,28 +21,47 @@
--ui-radius: 12px; --ui-radius: 12px;
} }
@media (prefers-color-scheme: dark) { :root[data-theme="dark"] {
:root { --ui-bg: #0f172a;
--ui-bg: #0f172a; --ui-bg-accent: #1e293b;
--ui-bg-accent: #1e293b; --ui-surface: #1e293b;
--ui-surface: #1e293b; --ui-surface-soft: #25314a;
--ui-surface-soft: #25314a; --ui-border: #334155;
--ui-border: #334155; --ui-text: #f1f5f9;
--ui-text: #f1f5f9; --ui-text-muted: #94a3b8;
--ui-text-muted: #94a3b8; --ui-title: #ffffff;
--ui-title: #ffffff; --ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4); --ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5); }
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
/* Make inputs visible in dark mode */ html {
input, select, textarea { /* Ensure viewport-fit is respected */
background-color: var(--ui-surface-soft) !important; viewport-fit: cover;
color: var(--ui-text) !important; }
border-color: var(--ui-border) !important;
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 { body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif; font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
color: var(--ui-text); color: var(--ui-text);
@@ -172,11 +191,16 @@ select:focus {
box-shadow: none; box-shadow: none;
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
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 { .navbar-brand {
font-weight: 800; font-weight: 800;
letter-spacing: 0.02em; letter-spacing: 0.02em;
/* Prevent navbar brand from extending into unsafe areas */
word-break: break-word;
} }
@media (max-width: 900px) { @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 and generate buttons with theme-aware text for better visibility */
.edit-button, .duplicate-button, .generate-qr-button { .edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-text) !important; /* Override any existing color */ color: var(--ui-text) !important; /* Override any existing color */
+5 -5
View File
@@ -11,7 +11,7 @@
</div> </div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;"> <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> <div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
{% if verify_result.ok %} {% if verify_result.ok %}
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div> <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> <div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
{% endif %} {% endif %}
</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;">Einträge</div> <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 style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
</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:0.85rem; color:#64748b;">Letzter Index</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div> <div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
</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:0.85rem; color:#64748b;">Mismatch Count</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div> <div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
</div> </div>
@@ -61,7 +61,7 @@
</div> </div>
{% endif %} {% 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> <h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
<div style="max-height:560px; overflow:auto;"> <div style="max-height:560px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;"> <table class="table" style="width:100%; border-collapse:collapse;">
+4 -4
View File
@@ -161,7 +161,7 @@
</div> </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 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 style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div> <div>
<h2 style="margin:0;">Rechnung erstellen</h2> <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 style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div> <div>
<label for="invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label> <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>
<div> <div>
<label for="invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label> <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>
<div> <div>
<label for="invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label> <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>
<div> <div>
<label for="invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label> <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> </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;"> <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>
<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;"> <table id="damage-table" style="width:100%; border-collapse:collapse;">
<thead> <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;">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;">Status</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th> <th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
+267 -92
View File
@@ -14,8 +14,20 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#2c3e50" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#2c3e50" id="meta-theme-color">
<meta name="theme-color" content="#1a252f" media="(prefers-color-scheme: dark)"> <script>
// Init theme early to prevent FOUC (Flash of Unstyled Content)
(function() {
var theme = localStorage.getItem('inventarsystem-theme');
if (theme === 'dark' || (!theme && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('meta-theme-color').setAttribute('content', '#1a252f');
} else {
document.documentElement.setAttribute('data-theme', 'light');
document.getElementById('meta-theme-color').setAttribute('content', '#2c3e50');
}
})();
</script>
<meta name="csrf-token" content="{{ csrf_token }}"> <meta name="csrf-token" content="{{ csrf_token }}">
<title>{% block title %}Inventarsystem{% endblock %}</title> <title>{% block title %}Inventarsystem{% endblock %}</title>
{% block head %} {% block head %}
@@ -117,21 +129,20 @@
--module-brand-icon: "📚"; --module-brand-icon: "📚";
} }
@media (prefers-color-scheme: dark) { :root[data-theme="dark"] {
:root { --module-primary-color: #1a252f;
--module-primary-color: #1a252f; --module-primary-light: #2c3e50;
--module-primary-light: #2c3e50; --module-accent-color: #2980b9;
--module-accent-color: #2980b9; --module-brand-icon: "📦";
--module-brand-icon: "📦"; --page-bg-gradient: linear-gradient(180deg, #0f172a, #1e293b);
--page-bg-gradient: linear-gradient(180deg, #0f172a, #1e293b); --page-bg-overlay1: rgba(56, 189, 248, 0.05);
--page-bg-overlay1: rgba(56, 189, 248, 0.05); --page-bg-overlay2: rgba(59, 130, 246, 0.06);
--page-bg-overlay2: rgba(59, 130, 246, 0.06); }
}
html[data-module="library"] { html[data-theme="dark"][data-module="library"] {
--module-primary-color: #4a354d; --module-primary-color: #4a354d;
--module-primary-light: #614666; --module-primary-light: #614666;
--module-accent-color: #8e44ad; --module-accent-color: #8e44ad;
}
} }
/* Global styles */ /* Global styles */
@@ -158,31 +169,87 @@
background-color: var(--module-primary-color) !important; background-color: var(--module-primary-color) !important;
position: sticky; position: sticky;
top: 0; top: 0;
padding-top: env(safe-area-inset-top, 0); /* iOS Safe Area */ /* iOS Safe Area insets for notches and rounded corners */
padding-top: env(safe-area-inset-top, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
z-index: 1900; z-index: 1900;
} }
.navbar .container-fluid { .navbar .container-fluid {
gap: 10px; gap: 8px;
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */ /* Ensure container respects safe areas on all sides */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
padding-bottom: env(safe-area-inset-bottom, 0);
} }
.navbar-brand { .navbar-brand {
font-weight: bold; font-weight: bold;
font-size: 1.4rem; font-size: 1.3rem;
padding-right: 10px;
white-space: nowrap;
flex-shrink: 0;
} }
.navbar-brand::before { .navbar-brand::before {
content: attr(data-icon); content: attr(data-icon);
margin-right: 8px; margin-right: 6px;
} }
.navbar-nav .nav-link { .navbar-nav .nav-link {
font-size: 1rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
padding: 0.6rem 0.85rem; padding: 0.5rem 0.75rem;
border-radius: 8px; border-radius: 6px;
transition: padding .18s ease, font-size .18s ease; transition: padding .18s ease, font-size .18s ease;
white-space: nowrap;
}
/* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) {
.navbar .container-fluid {
gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px);
padding-right: max(env(safe-area-inset-right, 0), 10px);
}
.navbar-brand {
font-size: 1.1rem;
padding-right: 8px;
}
.navbar-brand::before {
margin-right: 4px;
}
.navbar-nav .nav-link {
font-size: 0.85rem;
padding: 0.45rem 0.6rem;
}
}
/* Small phones responsive adjustments */
@media (max-width: 576px) {
.navbar .container-fluid {
gap: 4px;
padding-left: max(env(safe-area-inset-left, 0), 8px);
padding-right: max(env(safe-area-inset-right, 0), 8px);
}
.navbar-brand {
font-size: 0.95rem;
padding-right: 6px;
}
.navbar-brand::before {
display: none;
}
.navbar-nav .nav-link {
font-size: 0.75rem;
padding: 0.4rem 0.5rem;
}
} }
.navbar.nav-compact .navbar-brand { .navbar.nav-compact .navbar-brand {
@@ -239,6 +306,7 @@
align-items: center; align-items: center;
margin-right: 10px; margin-right: 10px;
width: min(420px, 42vw); width: min(420px, 42vw);
min-width: 0;
} }
.function-search-form { .function-search-form {
@@ -246,10 +314,13 @@
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
min-width: 0;
} }
.function-search-input { .function-search-input {
width: 100%; flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 38px; min-height: 38px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
@@ -270,6 +341,7 @@
} }
.function-search-btn { .function-search-btn {
flex: 0 0 auto;
min-height: 38px; min-height: 38px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.45); border: 1px solid rgba(255, 255, 255, 0.45);
@@ -472,9 +544,48 @@
margin-top: 4px; margin-top: 4px;
} }
.navbar-collapse {
width: 100%;
}
.navbar-collapse > .d-flex {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.function-search-wrap { .function-search-wrap {
width: 100%; width: 100%;
max-width: 100%;
margin: 8px 0 10px; margin: 8px 0 10px;
flex: 1 1 100%;
}
.function-search-form {
width: 100%;
max-width: 100%;
flex-wrap: nowrap;
}
.function-search-input {
width: auto;
min-width: 0;
flex: 1 1 auto;
}
.function-search-btn {
flex: 0 0 auto;
white-space: nowrap;
}
.navbar-text {
margin-right: 0 !important;
width: 100%;
}
.user-menu-wrap {
align-self: flex-start;
} }
.navbar-nav .nav-item { .navbar-nav .nav-item {
@@ -495,7 +606,7 @@
/* Account dropdown stays light theme on mobile */ /* Account dropdown stays light theme on mobile */
.dropdown-menu-end { .dropdown-menu-end {
background-color: #ffffff; background-color: var(--ui-surface);
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
} }
@@ -504,7 +615,7 @@
} }
.dropdown-menu-end .dropdown-item:hover { .dropdown-menu-end .dropdown-item:hover {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
color: #007bff; color: #007bff;
} }
} }
@@ -846,12 +957,12 @@
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %} {% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a> <a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
</li> </li>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %} {% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
@@ -861,20 +972,18 @@
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a> <a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto"> <li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen"> <a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen Mehr Optionen
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li> <li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %} {% if current_permissions.pages.get('manage_filters', True) %}
@@ -967,12 +1076,12 @@
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %} {% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a> <a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
</li> </li>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %} {% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
@@ -982,20 +1091,18 @@
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a> <a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto"> <li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen"> <a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen Mehr Optionen
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %} {% if current_permissions.pages.get('library_loans_admin', True) %}
@@ -1098,7 +1205,7 @@
#onboarding-modal { #onboarding-modal {
width: min(680px, 100%); width: min(680px, 100%);
background: #ffffff; background: var(--ui-surface);
border-radius: 14px; border-radius: 14px;
border: 1px solid #dbe3ee; border: 1px solid #dbe3ee;
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.25); box-shadow: 0 18px 35px rgba(15, 23, 42, 0.25);
@@ -1573,8 +1680,15 @@
const libraryNavList = document.getElementById('libraryNavList'); const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor'); const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) { function initNavbarOverflow(navList, navOverflowAnchor, options) {
if (!navList || !navOverflowAnchor) return; if (!navList) return;
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
const navRoot = navList.closest('nav.navbar'); const navRoot = navList.closest('nav.navbar');
const navCollapse = navList.closest('.navbar-collapse'); const navCollapse = navList.closest('.navbar-collapse');
@@ -1602,45 +1716,35 @@
return Array.from(navList.children).filter(function(li){ return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) return false; if (!(li instanceof HTMLElement)) return false;
if (!li.classList.contains('nav-item')) return false; if (!li.classList.contains('nav-item')) return false;
if (li.id === navOverflowAnchor.id) return false; if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
if (li.dataset.overflowControl === 'true') return false; if (li.dataset.overflowControl === 'true') return false;
if (li.dataset.navFixed === 'true') return false; if (li.dataset.navFixed === 'true') return false;
if (moreControlItem && li === moreControlItem) return false;
return li.style.display !== 'none'; return li.style.display !== 'none';
}); });
} }
function clearOverflowControls() { function clearOverflowControls() {
if (!navList) return; if (!moreMenu) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){ moreMenu.innerHTML = moreMenuOriginal;
node.remove(); if (moreControlItem) {
}); moreControlItem.style.display = '';
}
if (moreToggle) {
moreToggle.textContent = moreLabelDefault;
}
} }
function restoreAllNavItems() { function restoreAllNavItems() {
if (!navList) return; if (!navList) return;
navList.querySelectorAll('li.nav-item').forEach(function(li){ navList.querySelectorAll('li.nav-item').forEach(function(li){
if (li.id === navOverflowAnchor.id) return; if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
if (li.dataset.overflowControl === 'true') return; if (li.dataset.overflowControl === 'true') return;
li.style.display = ''; li.style.display = '';
}); });
clearOverflowControls(); clearOverflowControls();
} }
function createOverflowControl() {
const li = document.createElement('li');
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
const toggleId = navOverflowAnchor.id + '-toggle';
const menuId = navOverflowAnchor.id + '-menu';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
return li;
}
function getNavCandidatePriority(item) { function getNavCandidatePriority(item) {
if (!(item instanceof HTMLElement)) { if (!(item instanceof HTMLElement)) {
return -1; return -1;
@@ -1723,33 +1827,70 @@
function rebuildOverflowControl(hiddenSources) { function rebuildOverflowControl(hiddenSources) {
clearOverflowControls(); clearOverflowControls();
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return; if (!moreMenu || hiddenSources.length === 0) return;
const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu');
const controlLink = control.querySelector(':scope > a.nav-link');
if (controlLink) {
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
}
const overflowFragment = document.createDocumentFragment();
hiddenSources.forEach(function(source){ hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu); appendSourceToOverflowMenu(source, overflowFragment);
}); });
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider'); const overflowWrap = document.createElement('div');
if (trailingDivider) { overflowWrap.appendChild(overflowFragment);
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
if (trailingDivider && trailingDivider.parentElement) {
trailingDivider.parentElement.remove(); trailingDivider.parentElement.remove();
} }
navList.insertBefore(control, navOverflowAnchor); const overflowEntries = Array.from(overflowWrap.childNodes);
if (overflowEntries.length > 0) {
const marker = document.createElement('li');
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
const firstExistingNode = moreMenu.firstChild;
moreMenu.insertBefore(marker, firstExistingNode);
let insertAfter = marker;
overflowEntries.forEach(function(node){
moreMenu.insertBefore(node, insertAfter.nextSibling);
insertAfter = node;
});
const divider = document.createElement('li');
divider.innerHTML = '<hr class="dropdown-divider">';
moreMenu.insertBefore(divider, insertAfter.nextSibling);
if (moreToggle) {
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
}
}
if (moreControlItem) {
moreControlItem.style.display = '';
}
} }
function adaptNavbarByWidth() { function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return; if (!navList) return;
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
function isNavOverflowing(bufferPx) {
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
return collapseOverflow || containerOverflow || listOverflow;
}
applyCompactMode(); applyCompactMode();
if (window.innerWidth < 992) { // Desktop/tablet-large: keep complete navigation visible.
if (!isMobileViewport) {
restoreAllNavItems();
return;
}
// Mobile menu is collapsed: avoid hiding links preemptively.
if (navCollapse && !navCollapse.classList.contains('show')) {
restoreAllNavItems(); restoreAllNavItems();
return; return;
} }
@@ -1758,8 +1899,9 @@
const hiddenSources = []; const hiddenSources = [];
let candidates = collectTopLevelNavSources(); let candidates = collectTopLevelNavSources();
const overflowBuffer = 12;
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates); const toHide = pickNextNavItemToHide(candidates);
if (!toHide) { if (!toHide) {
break; break;
@@ -1771,8 +1913,9 @@
rebuildOverflowControl(hiddenSources); rebuildOverflowControl(hiddenSources);
// One final pass in case the overflow menu label/count itself changed row width.
candidates = collectTopLevelNavSources(); candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates); const toHide = pickNextNavItemToHide(candidates);
if (!toHide) { if (!toHide) {
break; break;
@@ -1810,12 +1953,12 @@
// Initialize overflow control for inventory navbar // Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) { if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor); initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
} }
// Initialize overflow control for library navbar // Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) { if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor); initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
} }
})(); })();
</script> </script>
@@ -1825,5 +1968,37 @@
<!-- Push Notifications Script --> <!-- Push Notifications Script -->
<script src="{{ url_for('static', filename='js/push-notifications.js') }}"></script> <script src="{{ url_for('static', filename='js/push-notifications.js') }}"></script>
<!-- Theme Toggle Script -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const toggleBtns = document.querySelectorAll('#themeToggleBtn');
if (toggleBtns.length === 0) return;
function updateIcons(theme) {
const isDark = theme === 'dark';
document.querySelectorAll('.theme-icon-light').forEach(icon => icon.style.display = isDark ? 'inline' : 'none');
document.querySelectorAll('.theme-icon-dark').forEach(icon => icon.style.display = isDark ? 'none' : 'inline');
}
// Get current setup from initial script in head
let currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
updateIcons(currentTheme);
toggleBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
currentTheme = currentTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', currentTheme);
localStorage.setItem('inventarsystem-theme', currentTheme);
document.getElementById('meta-theme-color').setAttribute('content', currentTheme === 'dark' ? '#1a252f' : '#2c3e50');
updateIcons(currentTheme);
});
});
});
</script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -53,7 +53,7 @@
max-width: 500px; max-width: 500px;
margin: 50px auto; margin: 50px auto;
padding: 30px; padding: 30px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
} }
+237 -1
View File
@@ -208,12 +208,23 @@
<script> <script>
// View mode persistence // View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode'; 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) { function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode); localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer'); const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader'); 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 => { items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode'); const cardContent = item.querySelector('.item-content.card-mode');
@@ -236,6 +247,229 @@ function setViewMode(mode) {
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table'); 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 // Initialize view mode
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card'; 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'; if (e.target === this) this.style.display = 'none';
}); });
}); });
initMobileWindowedLibraryLoading();
}); });
function displayLibraryItemDetail(item) { function displayLibraryItemDetail(item) {
+8 -8
View File
@@ -52,7 +52,7 @@
} }
.summary-card { .summary-card {
background: #fff; background: var(--ui-surface);
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 14px; border-radius: 14px;
padding: 14px 16px; padding: 14px 16px;
@@ -85,11 +85,11 @@
padding: 11px 14px; padding: 11px 14px;
border: 1px solid #d6dde6; border: 1px solid #d6dde6;
border-radius: 12px; border-radius: 12px;
background: #fff; background: var(--ui-surface);
} }
.panel { .panel {
background: #fff; background: var(--ui-surface);
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 18px; border-radius: 18px;
padding: 18px; padding: 18px;
@@ -120,7 +120,7 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #64748b; color: #64748b;
background: #f8fafc; background: var(--ui-surface-soft);
} }
.library-table tr:hover td { .library-table tr:hover td {
@@ -408,7 +408,7 @@
</div> </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 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 style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div> <div>
<h2 style="margin:0;">Rechnung erstellen</h2> <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 style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div> <div>
<label for="damage-invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label> <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>
<div> <div>
<label for="damage-invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label> <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>
<div> <div>
<label for="damage-invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label> <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>
<div> <div>
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label> <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 { .invoice-card {
background: #fff; background: var(--ui-surface);
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 16px; border-radius: 16px;
padding: 18px; padding: 18px;
@@ -65,7 +65,7 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #64748b; color: #64748b;
background: #f8fafc; background: var(--ui-surface-soft);
} }
.invoice-table tr:hover td { .invoice-table tr:hover td {
+13 -13
View File
@@ -18,7 +18,7 @@
.library-header h1 { .library-header h1 {
margin: 0 0 5px 0; margin: 0 0 5px 0;
font-size: 2em; font-size: 2em;
color: #333; color: var(--ui-text);
} }
.library-header p { .library-header p {
@@ -48,7 +48,7 @@
/* Scanner panel */ /* Scanner panel */
.library-scan-panel { .library-scan-panel {
background: #ffffff; background: var(--ui-surface);
border: 1px solid #d9dde4; border: 1px solid #d9dde4;
border-radius: 8px; border-radius: 8px;
padding: 14px; padding: 14px;
@@ -68,7 +68,7 @@
border: 1px solid #d0d7e2; border: 1px solid #d0d7e2;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
background: #fff; background: var(--ui-surface);
} }
.library-scan-status { .library-scan-status {
@@ -95,7 +95,7 @@
border: 1px solid #d9dde4; border: 1px solid #d9dde4;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #fff; background: var(--ui-surface);
max-width: 460px; max-width: 460px;
} }
@@ -111,7 +111,7 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
color: #333; color: var(--ui-text);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@@ -160,7 +160,7 @@
.filter-item label { .filter-item label {
font-weight: 600; font-weight: 600;
font-size: 0.9em; font-size: 0.9em;
color: #333; color: var(--ui-text);
margin-bottom: 6px; margin-bottom: 6px;
} }
@@ -206,7 +206,7 @@
} }
.library-items-table thead { .library-items-table thead {
background: #f8f9fa; background: var(--ui-surface-soft);
border-bottom: 2px solid #ddd; border-bottom: 2px solid #ddd;
} }
@@ -214,7 +214,7 @@
padding: 14px 16px; padding: 14px 16px;
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;
color: #333; color: var(--ui-text);
font-size: 0.9em; font-size: 0.9em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -228,7 +228,7 @@
} }
.library-items-table tbody tr:hover { .library-items-table tbody tr:hover {
background: #f8f9fa; background: var(--ui-surface-soft);
} }
.library-items-table tbody tr:last-child td { .library-items-table tbody tr:last-child td {
@@ -238,7 +238,7 @@
/* Table cells */ /* Table cells */
.table-title { .table-title {
font-weight: 600; font-weight: 600;
color: #333; color: var(--ui-text);
} }
.table-status { .table-status {
@@ -320,7 +320,7 @@
} }
.modal-content { .modal-content {
background-color: #fff; background-color: var(--ui-surface);
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
max-width: 900px; max-width: 900px;
@@ -373,7 +373,7 @@
} }
.close:hover { .close:hover {
color: #333; color: var(--ui-text);
} }
/* Mobile responsive */ /* Mobile responsive */
@@ -494,7 +494,7 @@
</div> </div>
<div class="filter-buttons"> <div class="filter-buttons">
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button> <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>
</div> </div>
+1 -1
View File
@@ -337,7 +337,7 @@
color: #0d6efd; color: #0d6efd;
} }
.license-content { .license-content {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 20px 30px; padding: 20px 30px;
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
+1 -1
View File
@@ -217,7 +217,7 @@
h1 { h1 {
text-align: center; text-align: center;
margin-bottom: 30px; margin-bottom: 30px;
color: #333; color: var(--ui-text);
} }
.filter-controls { .filter-controls {
+92 -22
View File
@@ -20,7 +20,7 @@
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
border-bottom: 1px solid var(--ui-border); border-bottom: 1px solid var(--ui-border);
background: #f8fafc; background: var(--ui-surface-soft);
} }
.calendar-header button { .calendar-header button {
@@ -36,7 +36,7 @@
} }
.calendar-header button:hover { .calendar-header button:hover {
background: #f1f5f9; background: var(--ui-bg);
} }
.calendar-header span { .calendar-header span {
@@ -80,7 +80,7 @@
} }
.cal-cell:not(.header):not(.empty):hover { .cal-cell:not(.header):not(.empty):hover {
background: #f1f5f9; background: var(--ui-bg);
} }
.cal-cell.empty { .cal-cell.empty {
@@ -115,7 +115,7 @@
.calendar-day-details { .calendar-day-details {
border-top: 1px solid #e7edf5; border-top: 1px solid #e7edf5;
padding: 12px; padding: 12px;
background: #f8fafc; background: var(--ui-surface-soft);
display: none; display: none;
} }
@@ -321,7 +321,7 @@
border: 1px solid #dc3545 !important; border: 1px solid #dc3545 !important;
} }
</style> </style>
<div class="container"> <div class="container admin-content-container">
<!-- Conflict warning banner (populated by JS) --> <!-- 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;"> <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> <strong>⚠ Reservierungskonflikt</strong>
@@ -904,7 +904,7 @@
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : ''; const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.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 // Get file with correct case handling
const image = file; const image = file;
@@ -1005,14 +1005,14 @@
card.innerHTML = ` card.innerHTML = `
<div class="card-content" data-item-id="${item._id}"> <div class="card-content" data-item-id="${item._id}">
<h3 class="item-name-cell">${item.Name}</h3> <h3 class="item-col-name">${item.Name}</h3>
<p class="item-ort-cell"><strong>Ort:</strong> ${item.Ort || '-'}</p> <p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-filter1-cell"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p> <p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-filter2-cell"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p> <p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-filter3-cell"><strong>Thema:</strong> ${filter3Display}${filter3More}</p> <p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
<p class="item-code-cell"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p> <p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-count-cell"><strong>Anzahl:</strong> ${groupedCount}</p> <p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${isGroupedItem ? `<p class="item-count-cell"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''} ${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
<div class="image-container"> <div class="image-container">
${imagesHtml} ${imagesHtml}
</div> </div>
@@ -1631,7 +1631,7 @@
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Anschaffungskosten:</div> <div class="detail-label">Anschaffungskosten:</div>
<div class="detail-value">${item.Anschaffungskosten || '-'}</div> <div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
</div> </div>
<div class="detail-group full-width"> <div class="detail-group full-width">
@@ -1646,7 +1646,7 @@
<span>🛠️</span> <span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span> <span id="toggle-damage-history-text">Historie anzeigen</span>
</button> </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 style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div> </div>
</div> </div>
@@ -2847,7 +2847,7 @@
} }
.filter-toggle, .clear-filter { .filter-toggle, .clear-filter {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 3px; border-radius: 3px;
padding: 3px 8px; padding: 3px 8px;
@@ -3028,7 +3028,7 @@
margin-right: 8px; margin-right: 8px;
border: 1px solid #dbe3ee; border: 1px solid #dbe3ee;
border-radius: 10px; border-radius: 10px;
background: #f8fafc; background: var(--ui-surface-soft);
color: var(--ui-text); color: var(--ui-text);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -3859,7 +3859,7 @@
max-height: 400px; max-height: 400px;
overflow: hidden; overflow: hidden;
border-radius: 8px; border-radius: 8px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
} }
/* Borrower and appointment info panels in modal */ /* Borrower and appointment info panels in modal */
@@ -3891,7 +3891,7 @@
.detail-value { color: #212529; flex: 1; word-wrap: break-word; } .detail-value { color: #212529; flex: 1; word-wrap: break-word; }
.detail-group.full-width .detail-label { margin-bottom: 5px; } .detail-group.full-width .detail-label { margin-bottom: 5px; }
.detail-group.full-width .detail-value { .detail-group.full-width .detail-value {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 10px; padding: 10px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
@@ -4174,7 +4174,7 @@
overflow-wrap: break-word !important; 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; font-size: 0.95rem !important;
} }
@@ -4598,4 +4598,74 @@ function openItemQuick(id){
} }
} }
</style> </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>
+14 -14
View File
@@ -104,7 +104,7 @@
border-radius: 8px; border-radius: 8px;
padding: 9px 12px; padding: 9px 12px;
cursor: pointer; cursor: pointer;
background: #f8fafc; background: var(--ui-surface-soft);
color: var(--ui-title); color: var(--ui-title);
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
@@ -152,7 +152,7 @@
} }
.filter-toggle, .clear-filter { .filter-toggle, .clear-filter {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 3px; border-radius: 3px;
padding: 3px 8px; padding: 3px 8px;
@@ -333,7 +333,7 @@
margin-right: 8px; margin-right: 8px;
border: 1px solid #dbe3ee; border: 1px solid #dbe3ee;
border-radius: 10px; border-radius: 10px;
background: #f8fafc; background: var(--ui-surface-soft);
color: var(--ui-text); color: var(--ui-text);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -706,7 +706,7 @@
padding: 15px; padding: 15px;
border: 1px solid var(--ui-border); border: 1px solid var(--ui-border);
border-radius: 5px; border-radius: 5px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
} }
.filter-inputs h3 { .filter-inputs h3 {
@@ -1049,7 +1049,7 @@
} }
.detail-group.full-width .detail-value { .detail-group.full-width .detail-value {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 10px; padding: 10px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
@@ -1069,7 +1069,7 @@
max-height: 400px; max-height: 400px;
overflow: hidden; overflow: hidden;
border-radius: 8px; border-radius: 8px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
} }
.modal-image { .modal-image {
@@ -2022,7 +2022,7 @@
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
border-bottom: 1px solid var(--ui-border); border-bottom: 1px solid var(--ui-border);
background: #f8fafc; background: var(--ui-surface-soft);
} }
.calendar-header button { .calendar-header button {
@@ -2038,7 +2038,7 @@
} }
.calendar-header button:hover { .calendar-header button:hover {
background: #f1f5f9; background: var(--ui-bg);
} }
.calendar-header span { .calendar-header span {
@@ -2082,7 +2082,7 @@
} }
.cal-cell:not(.header):not(.empty):hover { .cal-cell:not(.header):not(.empty):hover {
background: #f1f5f9; background: var(--ui-bg);
} }
.cal-cell.empty { .cal-cell.empty {
@@ -2117,7 +2117,7 @@
.calendar-day-details { .calendar-day-details {
border-top: 1px solid #e7edf5; border-top: 1px solid #e7edf5;
padding: 12px; padding: 12px;
background: #f8fafc; background: var(--ui-surface-soft);
display: none; display: none;
} }
@@ -2584,7 +2584,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<input type="date" id="edit-year" name="anschaffungsjahr"> <input type="date" id="edit-year" name="anschaffungsjahr">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="edit-cost">Anschaffungskosten:</label> <label for="edit-cost">Anschaffungskosten (€):</label>
<input id="edit-cost" name="anschaffungskosten"> <input id="edit-cost" name="anschaffungskosten">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -3530,7 +3530,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : ''; const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.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 // Check if the image already has a full URL path or just the filename
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ? const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
image : image :
@@ -4441,7 +4441,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Anschaffungskosten:</div> <div class="detail-label">Anschaffungskosten:</div>
<div class="detail-value">${item.Anschaffungskosten || '-'}</div> <div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
</div> </div>
<div class="detail-group full-width"> <div class="detail-group full-width">
@@ -4456,7 +4456,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<span>🛠️</span> <span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span> <span id="toggle-damage-history-text">Historie anzeigen</span>
</button> </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 style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div> </div>
</div> </div>
+3 -3
View File
@@ -78,12 +78,12 @@
border-radius: 5px; border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1); box-shadow: 0 2px 5px rgba(0,0,0,0.1);
margin-bottom: 20px; margin-bottom: 20px;
background-color: #fff; background-color: var(--ui-surface);
border: 1px solid #e9ecef; border: 1px solid #e9ecef;
} }
.card-header { .card-header {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 15px 20px; padding: 15px 20px;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid #e9ecef;
} }
@@ -170,7 +170,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px; padding: 10px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border-radius: 4px; border-radius: 4px;
border-left: 3px solid #6c757d; border-left: 3px solid #6c757d;
} }
+4 -4
View File
@@ -61,7 +61,7 @@
bottom: 125%; bottom: 125%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background-color: #333; background-color: var(--ui-text);
color: white; color: white;
padding: 5px 10px; padding: 5px 10px;
border-radius: 4px; border-radius: 4px;
@@ -405,7 +405,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
.borrowed-item { .borrowed-item {
background-color: #fff; background-color: var(--ui-surface);
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
@@ -457,7 +457,7 @@ document.addEventListener('DOMContentLoaded', function() {
.exemplar-details { .exemplar-details {
margin-top: 10px; margin-top: 10px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
border-left: 3px solid #6c757d; border-left: 3px solid #6c757d;
@@ -515,7 +515,7 @@ document.addEventListener('DOMContentLoaded', function() {
.no-items-message { .no-items-message {
text-align: center; text-align: center;
padding: 30px; padding: 30px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border-radius: 5px; border-radius: 5px;
color: #6c757d; color: #6c757d;
margin-top: 20px; margin-top: 20px;
+2 -2
View File
@@ -17,7 +17,7 @@
<div id="push-notification-settings" style="margin-bottom: 24px;"></div> <div id="push-notification-settings" style="margin-bottom: 24px;"></div>
<div style="display:grid; grid-template-columns:1fr; gap:14px;"> <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> <h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
{% if user_notifications %} {% if user_notifications %}
<div style="display:grid; gap:10px;"> <div style="display:grid; gap:10px;">
@@ -53,7 +53,7 @@
</section> </section>
{% if is_admin_user %} {% 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> <h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
{% if admin_notifications %} {% if admin_notifications %}
<div style="display:grid; gap:10px;"> <div style="display:grid; gap:10px;">
+4 -4
View File
@@ -121,7 +121,7 @@
--primary-dark: #2980b9; --primary-dark: #2980b9;
--success-color: #2ecc71; --success-color: #2ecc71;
--error-color: #e74c3c; --error-color: #e74c3c;
--text-color: #333; --text-color: var(--ui-text);
--light-bg: #f9f9f9; --light-bg: #f9f9f9;
--border-radius: 8px; --border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1); --shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
@@ -163,7 +163,7 @@ body {
} }
.content { .content {
background-color: #fff; background-color: var(--ui-surface);
padding: 2rem; padding: 2rem;
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -345,7 +345,7 @@ input::placeholder {
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
background: #f8fafc; background: var(--ui-surface-soft);
} }
.password-rules-title { .password-rules-title {
@@ -406,7 +406,7 @@ input::placeholder {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 10px;
background: #f8fafc; background: var(--ui-surface-soft);
} }
.permission-panel h4 { .permission-panel h4 {
+1 -1
View File
@@ -129,7 +129,7 @@ function createLoadingIndicator(message) {
} }
.reset-loading-message { .reset-loading-message {
color: #333; color: var(--ui-text);
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
} }
+8 -8
View File
@@ -207,7 +207,7 @@
} }
.reset-item-info { .reset-item-info {
background: #f8f9fa; background: var(--ui-surface-soft);
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
margin-bottom: 25px; margin-bottom: 25px;
@@ -216,7 +216,7 @@
.reset-item-info h3 { .reset-item-info h3 {
margin: 0 0 15px 0; margin: 0 0 15px 0;
color: #333; color: var(--ui-text);
font-size: 1.3rem; font-size: 1.3rem;
} }
@@ -232,7 +232,7 @@
} }
.reset-item-details strong { .reset-item-details strong {
color: #333; color: var(--ui-text);
} }
.reset-warning-section { .reset-warning-section {
@@ -285,7 +285,7 @@
flex-direction: column; flex-direction: column;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
color: #333; color: var(--ui-text);
} }
.reset-action-desc { .reset-action-desc {
@@ -298,7 +298,7 @@
.active-borrowings { .active-borrowings {
margin-top: 10px; margin-top: 10px;
padding: 10px; padding: 10px;
background: #f8f9fa; background: var(--ui-surface-soft);
border-radius: 5px; border-radius: 5px;
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -321,7 +321,7 @@
.reset-options-section h4 { .reset-options-section h4 {
margin: 0 0 15px 0; margin: 0 0 15px 0;
color: #333; color: var(--ui-text);
} }
.reset-option { .reset-option {
@@ -365,7 +365,7 @@
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500; font-weight: 500;
color: #333; color: var(--ui-text);
} }
.reset-reason-section textarea { .reset-reason-section textarea {
@@ -391,7 +391,7 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 15px; gap: 15px;
background: #f8f9fa; background: var(--ui-surface-soft);
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
} }
@@ -45,7 +45,7 @@
} }
h1 { h1 {
color: #333; color: var(--ui-text);
font-size: 28px; font-size: 28px;
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -67,7 +67,7 @@
} }
.info-box strong { .info-box strong {
color: #333; color: var(--ui-text);
} }
.info-box p { .info-box p {
@@ -106,7 +106,7 @@
.btn-secondary { .btn-secondary {
background: #e0e0e0; background: #e0e0e0;
color: #333; color: var(--ui-text);
} }
.btn-secondary:hover { .btn-secondary:hover {
+2 -2
View File
@@ -22,7 +22,7 @@
} }
.student-card-form { .student-card-form {
background: #f5f5f5; background: var(--ui-bg);
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 30px; margin-bottom: 30px;
@@ -43,7 +43,7 @@
.form-group label { .form-group label {
font-weight: 600; font-weight: 600;
margin-bottom: 5px; margin-bottom: 5px;
color: #333; color: var(--ui-text);
} }
.form-group input, .form-group input,
+1 -1
View File
@@ -1601,7 +1601,7 @@ document.addEventListener('DOMContentLoaded', function() {
.booking-summary { .booking-summary {
margin: 15px 0; margin: 15px 0;
padding: 10px; padding: 10px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-size: 0.9em; font-size: 0.9em;
+6 -6
View File
@@ -72,7 +72,7 @@
.tutorial-side { .tutorial-side {
grid-column: span 12; grid-column: span 12;
background: #ffffff; background: var(--ui-surface);
border: 1px solid #cfdbe8; border: 1px solid #cfdbe8;
border-radius: 16px; border-radius: 16px;
padding: 16px; padding: 16px;
@@ -102,7 +102,7 @@
.workflow-nav button { .workflow-nav button {
border: 1px solid #dbe3ee; border: 1px solid #dbe3ee;
background: #f8fafc; background: var(--ui-surface-soft);
color: #0f172a; color: #0f172a;
border-radius: 12px; border-radius: 12px;
padding: 11px 12px; padding: 11px 12px;
@@ -133,7 +133,7 @@
.workflow-step { .workflow-step {
grid-column: span 12; grid-column: span 12;
background: #ffffff; background: var(--ui-surface);
border: 1px solid #cfdbe8; border: 1px solid #cfdbe8;
border-radius: 16px; border-radius: 16px;
padding: 18px; padding: 18px;
@@ -331,17 +331,17 @@
<h3>Tägliche Aufgaben</h3> <h3>Tägliche Aufgaben</h3>
<p>Das ist der gewöhnliche Arbeitsablauf während eines Schultages.</p> <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-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> <p style="margin: 0; color: #334155;">Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.</p>
</div> </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-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> <p style="margin: 0; color: #334155;">Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.</p>
</div> </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-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> <p style="margin: 0; color: #334155;">Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.</p>
</div> </div>
+13 -13
View File
@@ -78,14 +78,14 @@
.book-info { .book-info {
padding: 15px; padding: 15px;
background-color: #fff; background-color: var(--ui-surface);
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 5px; border-radius: 5px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.book-info h4 { .book-info h4 {
color: #333; color: var(--ui-text);
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-size: 1.2em; font-size: 1.2em;
font-weight: bold; font-weight: bold;
@@ -115,7 +115,7 @@
} }
.book-description h5 { .book-description h5 {
color: #333; color: var(--ui-text);
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 1em; font-size: 1em;
font-weight: bold; font-weight: bold;
@@ -125,7 +125,7 @@
max-height: 150px; max-height: 150px;
overflow-y: auto; overflow-y: auto;
padding: 5px; padding: 5px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border: 1px solid #e9ecef; border: 1px solid #e9ecef;
border-radius: 3px; border-radius: 3px;
font-size: 0.9em; font-size: 0.9em;
@@ -187,7 +187,7 @@
font-style: italic; font-style: italic;
text-align: center; text-align: center;
padding: 20px; padding: 20px;
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 4px; border-radius: 4px;
} }
@@ -334,7 +334,7 @@
} }
.popup-content p { .popup-content p {
color: #333; color: var(--ui-text);
margin: 15px 0; margin: 15px 0;
line-height: 1.5; line-height: 1.5;
font-size: 1em; font-size: 1em;
@@ -380,7 +380,7 @@
} }
.popup-close-x:hover { .popup-close-x:hover {
color: #333; color: var(--ui-text);
} }
/* Upload form styles */ /* Upload form styles */
@@ -388,13 +388,13 @@
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
background-color: #fff; background-color: var(--ui-surface);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1); box-shadow: 0 2px 10px rgba(0,0,0,0.1);
} }
.upload-form h1 { .upload-form h1 {
color: #333; color: var(--ui-text);
margin-bottom: 30px; margin-bottom: 30px;
text-align: center; text-align: center;
font-size: 2em; font-size: 2em;
@@ -408,7 +408,7 @@
display: block; display: block;
margin-bottom: 5px; margin-bottom: 5px;
font-weight: bold; font-weight: bold;
color: #333; color: var(--ui-text);
} }
.form-group input, .form-group input,
@@ -428,14 +428,14 @@
} }
.filter-inputs { .filter-inputs {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 20px; padding: 20px;
border-radius: 5px; border-radius: 5px;
margin: 20px 0; margin: 20px 0;
} }
.filter-inputs h3 { .filter-inputs h3 {
color: #333; color: var(--ui-text);
margin-bottom: 15px; margin-bottom: 15px;
font-size: 1.2em; font-size: 1.2em;
} }
@@ -863,7 +863,7 @@
<input type="date" id="anschaffungsjahr" name="anschaffungsjahr"> <input type="date" id="anschaffungsjahr" name="anschaffungsjahr">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="anschaffungskosten">Anschaffungskosten</label> <label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten"> <input id="anschaffungskosten" name="anschaffungskosten">
</div> </div>
<div class="form-group"> <div class="form-group">
+3 -3
View File
@@ -407,14 +407,14 @@
} }
.filter-bar { .filter-bar {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 15px; padding: 15px;
border-radius: 6px; border-radius: 6px;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
} }
.password-requirements { .password-requirements {
background-color: #f8f9fa; background-color: var(--ui-surface-soft);
padding: 10px; padding: 10px;
border-radius: 4px; border-radius: 4px;
margin-top: 10px; margin-top: 10px;
@@ -439,7 +439,7 @@
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
background: #f8fafc; background: var(--ui-surface-soft);
} }
.permissions-group h6 { .permissions-group h6 {
+93 -4
View File
@@ -10,28 +10,104 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
from flask import request, g, has_request_context from flask import request, g, has_request_context
from functools import wraps from functools import wraps
import logging import logging
import os
import re
import settings as cfg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Tenant registry: maps subdomain/tenant_id to database name # Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {} 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_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 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: class TenantContext:
""" """
Manages current tenant context for request lifecycle. 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): def __init__(self):
self.tenant_id = None self.tenant_id = None
self.db_name = None self.db_name = None
self.subdomain = None self.subdomain = None
self.port = None
self.config = {}
def resolve_tenant(self): def resolve_tenant(self):
""" """
Resolve tenant from request context. Resolve tenant from request context.
Priority: Header > Subdomain > Default Priority: Header > Port mapping > Subdomain > Default
""" """
if not has_request_context(): if not has_request_context():
return None return None
@@ -40,10 +116,21 @@ class TenantContext:
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip() tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header: if tenant_from_header:
self.tenant_id = 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) return self._get_db_name(tenant_from_header)
# Priority 2: Subdomain extraction # Priority 2: Port-based tenant mapping
host = request.host.lower() host = request.host.lower()
_, port = _parse_port_from_host(host)
self.port = port
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)
return self._get_db_name(tenant_from_port)
# Priority 3: Subdomain extraction
parts = host.split('.') parts = host.split('.')
# Extract subdomain from host # Extract subdomain from host
@@ -56,10 +143,12 @@ class TenantContext:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'): if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
return self._get_db_name(potential_subdomain) return self._get_db_name(potential_subdomain)
# Fallback to default tenant if no subdomain detected # Fallback to default tenant if no tenant identifier found
self.tenant_id = 'default' self.tenant_id = 'default'
self.config = get_tenant_config('default')
return self._get_db_name('default') return self._get_db_name('default')
def _get_db_name(self, tenant_id): def _get_db_name(self, tenant_id):
+10 -3
View File
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}" COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}" KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}" MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}" DB_NAME="${DB_NAME:-inventar_default}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}" MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}" INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}" INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}" BACKUP_MODE="${BACKUP_MODE:-auto}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
DOCKER_AVAILABLE=0 DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=() DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false USE_NULL_OUTPUT=false
@@ -58,6 +59,8 @@ Options:
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter) --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) --min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE) --mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
-h|--help Show this help and exit -h|--help Show this help and exit
EOF EOF
} }
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
MIN_KEEP="$2"; shift 2;; MIN_KEEP="$2"; shift 2;;
--mode) --mode)
BACKUP_MODE="$2"; shift 2;; BACKUP_MODE="$2"; shift 2;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"; shift;;
-h|--help) -h|--help)
usage; exit 0;; usage; exit 0;;
*) *)
@@ -102,12 +109,12 @@ log_message() {
init_docker_compose() { init_docker_compose() {
if docker compose version >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1 DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml") DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
if sudo docker compose version >/dev/null 2>&1; then if sudo docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1 DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml") DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
-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
+4 -2
View File
@@ -1,9 +1,9 @@
{ {
"dbg": false, "dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012", "key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.0.2", "ver": "0.6.44",
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 443, "port": 8000,
"mongodb": { "mongodb": {
"host": "localhost", "host": "localhost",
@@ -50,6 +50,8 @@
} }
}, },
"tenants": {},
"allowed_extensions": [ "allowed_extensions": [
"png", "jpg", "jpeg", "gif", "png", "jpg", "jpeg", "gif",
"hevc", "heif", "hevc", "heif",
+20 -37
View File
@@ -1,42 +1,25 @@
# Multi-Tenant Optimized Docker Compose # 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 # Each instance: ~50MB base + 20-30MB per 20 users
# #
# Usage: # Usage:
# docker-compose -f docker-compose-multitenant.yml up -d # docker-compose -f docker-compose-multitenant.yml up -d
# #
# Scale example: To support 10 tenants with 20 users each: # Example: Start the stack and expose the app on host port 10000
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10 # INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
version: '3.9'
services: services:
nginx: # Management Container for multi-tenant scripts
image: nginx:1.27-alpine tenant-manager:
container_name: inventarsystem-nginx image: docker:cli
restart: unless-stopped container_name: tenant-manager
depends_on: restart: "no"
app: profiles:
condition: service_started - tools
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes: volumes:
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro - /var/run/docker.sock:/var/run/docker.sock
- ./certs:/etc/nginx/certs:ro - .:/workspace
- ./docker/nginx/acme:/etc/nginx/acme:ro entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
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
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -59,9 +42,8 @@ services:
image: mongo:7.0 image: mongo:7.0
container_name: inventarsystem-mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db expose:
ports: - "27017"
- "27017:27017"
volumes: volumes:
- mongodb_data:/data/db - mongodb_data:/data/db
- ./docker/mongo:/docker-entrypoint-initdb.d:ro - ./docker/mongo:/docker-entrypoint-initdb.d:ro
@@ -82,6 +64,8 @@ services:
args: args:
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
OPTIMIZATION_LEVEL: 2 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 restart: unless-stopped
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
@@ -90,13 +74,14 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
networks: networks:
- inventar-net - inventar-net
expose: expose:
- "8000" - "8000"
volumes: volumes:
- ./config.json:/app/config.json:ro - ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads:cached - app_uploads:/app/Web/uploads:cached
- app_thumbnails:/app/Web/thumbnails:cached - app_thumbnails:/app/Web/thumbnails:cached
- app_previews:/app/Web/previews:cached - app_previews:/app/Web/previews:cached
@@ -181,9 +166,6 @@ volumes:
networks: networks:
inventar-net: inventar-net:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
# Scale guidance: # Scale guidance:
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB # 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
@@ -196,3 +178,4 @@ networks:
# - Medium (5-10 tenants): 4GB RAM, 2-4 CPU cores # - Medium (5-10 tenants): 4GB RAM, 2-4 CPU cores
# - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores # - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores
# - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering # - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering
+26 -72
View File
@@ -1,87 +1,41 @@
version: "3.8"
services: services:
nginx: app:
image: nginx:1.27-alpine build: .
container_name: inventarsystem-nginx container_name: inventory-app
restart: unless-stopped 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: depends_on:
app: - mongodb
condition: service_started - redis
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: mongodb:
image: mongo:7.0 image: mongo:latest
container_name: inventarsystem-mongodb container_name: mongodb
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongodb_data:/data/db - mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis: redis:
image: redis:7-alpine image: redis:alpine
container_name: inventarsystem-redis container_name: redis
restart: unless-stopped 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: cloudflared:
build: image: cloudflare/cloudflared:latest
context: . container_name: cloudflared
dockerfile: Dockerfile
container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
security_opt: # Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
- no-new-privileges:true command: tunnel run homeserver
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"
volumes: volumes:
- ./config.json:/app/config.json:ro - ./config.yml:/etc/cloudflared/config.yml
- ./Web:/app/Web:ro - ./credentials.json:/etc/cloudflared/credentials.json
- app_uploads:/app/Web/uploads depends_on:
- app_thumbnails:/app/Web/thumbnails - app
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
volumes: volumes:
mongodb_data: mongo_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
redis_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 # Main HTTPS server block
server { server {
listen 443 ssl http2; listen 443 ssl;
http2 on;
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$; server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
# SSL Configuration (wildcard certificate) # SSL Configuration (wildcard certificate)
@@ -75,9 +76,9 @@ server {
application/x-javascript application/xml+rss application/json; application/x-javascript application/xml+rss application/json;
gzip_comp_level 5; gzip_comp_level 5;
# Access/Error logs with tenant in filename # Access/Error logs
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k; access_log /var/log/nginx/inventar_access.log combined buffer=32k;
error_log /var/log/nginx/inventar_error_${tenant}.log warn; error_log /var/log/nginx/inventar_error.log warn;
# Root location - proxy to app with tenant context # Root location - proxy to app with tenant context
location / { location / {
+29 -10
View File
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR="" TMP_DIR=""
COMPOSE_FILE="docker-compose-multitenant.yml"
cleanup_tmp_dir() { cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
pin_compose_app_image() { pin_compose_app_image() {
local tag="$1" local tag="$1"
local compose_file local compose_file
compose_file="$PROJECT_DIR/docker-compose.yml"
if [ ! -f "$compose_file" ]; then for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
echo "Warning: $compose_file not found; cannot pin app image" if [ ! -f "$compose_file" ]; then
return 0 continue
fi fi
python3 - <<'PY' "$compose_file" "$tag" python3 - <<'PY' "$compose_file" "$tag"
import re import re
import sys 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: with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out) f.writelines(out)
PY PY
done
} }
install_docker_if_missing() { install_docker_if_missing() {
@@ -173,6 +175,8 @@ Options:
--remove-legacy-system Remove old host MongoDB/system after successful import --remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install --skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup --cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME) --legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI) --legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration --legacy-system-dir <path> Optional old system directory to remove after migration
@@ -199,6 +203,14 @@ parse_args() {
CLEANUP_OLD_REMOVE_CRON=true CLEANUP_OLD_REMOVE_CRON=true
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--legacy-db-name) --legacy-db-name)
LEGACY_DB_NAME="$2" LEGACY_DB_NAME="$2"
shift 2 shift 2
@@ -280,14 +292,21 @@ backup_legacy_database() {
restore_legacy_backup_into_docker() { restore_legacy_backup_into_docker() {
local i local i
local compose_path
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
return 0 return 0
fi 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..." echo "Waiting for Docker MongoDB to become ready..."
for i in $(seq 1 60); do 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 break
fi fi
sleep 2 sleep 2
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
fi fi
echo "Importing legacy backup into Docker MongoDB..." 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." echo "Legacy DB import completed."
} }
@@ -454,8 +473,8 @@ EOF
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$TMP_DIR/.docker-build.env" <<EOF cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443 INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
EOF EOF
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env" sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
+252
View File
@@ -0,0 +1,252 @@
#!/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 " -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 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
}
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"
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; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
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'); import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' removed successfully."
else
echo "Error: Application container not running."
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 active tenants (Databases):"
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))
prefix = f'{settings.MONGODB_DB}_'
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
for db in dbs:
print(f'- {db.replace(prefix, \"\")}')
"
else
echo "Error: Application container not running."
fi
;;
*)
echo "Unknown command: $COMMAND"
show_help
;;
esac
exit 0
+2 -1
View File
@@ -12,5 +12,6 @@ redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl
cryptography cryptography>=42.0.0
pywebpush pywebpush
py-vapid>=1.9.0
+51 -4
View File
@@ -1,7 +1,54 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail 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" COMPOSE_FILE="docker-compose-multitenant.yml"
"$SCRIPT_DIR/start.sh" ENV_FILE="$SCRIPT_DIR/.docker-build.env"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.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..."
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 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 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 app
fi
fi
echo "Cleaning up unused Docker images..."
docker image prune -f
echo "App restart complete."
+14 -3
View File
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log" LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)" WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}" DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
SOURCE_PATH="" SOURCE_PATH=""
BACKUP_DATE="" BACKUP_DATE=""
DROP_DATABASE=false DROP_DATABASE=false
RESTART_SERVICES=false RESTART_SERVICES=false
STAGED_PATH="" STAGED_PATH=""
COMPOSE_FILE="docker-compose-multitenant.yml"
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups" BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups" BACKUP_ROOT_SYSTEM="/var/backups"
@@ -56,6 +57,8 @@ Options:
- latest: newest from local/system backup roots - latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore) --drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore --restart-services Restart stack after restore
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--list List detected backup candidates --list List detected backup candidates
--help Show this help --help Show this help
@@ -68,12 +71,12 @@ EOF
setup_compose() { setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose) DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose) DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
@@ -459,6 +462,14 @@ parse_args() {
RESTART_SERVICES=true RESTART_SERVICES=true
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--list) --list)
list_backups list_backups
exit 0 exit 0
+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 "$@"
+193 -186
View File
@@ -7,17 +7,27 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env" ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo" APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist" DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO="" SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo" SUDO="sudo"
fi fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0" NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="80" HTTP_PORT_VALUE="10000"
HTTPS_PORT_VALUE="443" HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}" CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}" 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() { usage() {
cat <<EOF cat <<EOF
@@ -26,6 +36,8 @@ Usage: $0 [options]
Options: Options:
--no-cron Do not create or update cron jobs --no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default) --with-cron Create/update cron jobs (default)
--multitenant Use the multi-tenant architecture deployment
--singletenant Use the legacy single-tenant compose deployment
-h, --help Show this help message -h, --help Show this help message
EOF EOF
} }
@@ -41,6 +53,14 @@ parse_args() {
CRON_SETUP_VALUE="1" CRON_SETUP_VALUE="1"
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -100,7 +120,11 @@ ensure_runtime_dependencies() {
local missing=() local missing=()
if ! command -v docker >/dev/null 2>&1; then 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 fi
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
@@ -124,14 +148,20 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}" if [ "$IS_ROOT" = "true" ]; then
apt_install "${missing[@]}" 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 fi
if command -v systemctl >/dev/null 2>&1; then if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true systemctl enable --now docker >/dev/null 2>&1 || true
if cron_setup_enabled; then 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
fi fi
} }
@@ -144,6 +174,11 @@ setup_boot_autostart_service() {
return 0 return 0
fi 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 if ! command -v systemctl >/dev/null 2>&1; then
return 0 return 0
fi fi
@@ -185,6 +220,11 @@ setup_scheduled_jobs() {
return 0 return 0
fi 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 if ! command -v crontab >/dev/null 2>&1; then
echo "Warning: crontab not available, skipping nightly update setup" echo "Warning: crontab not available, skipping nightly update setup"
return 0 return 0
@@ -195,101 +235,17 @@ setup_scheduled_jobs() {
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1" backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
local existing_cron local existing_cron
if [ "$(id -u)" -eq 0 ]; then existing_cron="$(crontab -l 2>/dev/null || true)"
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
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 "$backup_line" echo "$update_line"
echo "$update_line" } | crontab -
} | 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
echo "Nightly backup scheduled at 02:30" echo "Nightly backup scheduled at 02:30"
echo "Nightly auto-update scheduled at 03:00" 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() { ensure_runtime_config_json() {
local config_path backup_path local config_path backup_path
config_path="$SCRIPT_DIR/config.json" config_path="$SCRIPT_DIR/config.json"
@@ -306,7 +262,7 @@ ensure_runtime_config_json() {
"ver": "2.6.5", "ver": "2.6.5",
"dbg": false, "dbg": false,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 443, "port": 8000,
"mongodb": { "mongodb": {
"host": "mongodb", "host": "mongodb",
"port": 27017, "port": 27017,
@@ -445,97 +401,106 @@ find_free_port() {
echo "$port" echo "$port"
} }
stack_owns_host_port() { parse_port_list() {
local requested_port="$1" local raw="$1"
local container_port="$2" local port
local mapped_port local ports=()
raw="${raw//,/ }"
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)" for port in $raw; do
if [ -z "$mapped_port" ]; then port="${port//[[:space:]]/}"
return 1 if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
fi ports+=("$port")
fi
mapped_port="${mapped_port##*:}" done
[ "$mapped_port" = "$requested_port" ] printf '%s\n' "${ports[@]}"
}
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
} }
configure_host_ports() { configure_host_ports() {
local requested_http requested_https local requested_http
local requested_ports
local ports=()
local occupied_ports=()
requested_http="" requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
if [ -z "$requested_http" ]; then
requested_http="80"
fi fi
if stack_owns_host_port "$requested_http" "80"; then if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
HTTP_PORT_VALUE="$requested_http" requested_ports="$INVENTAR_HTTP_PORTS"
elif port_in_use "$requested_http"; then fi
stop_host_nginx_services || true
if ! port_in_use "$requested_http"; then if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
HTTP_PORT_VALUE="$requested_http" requested_ports="$INVENTAR_HTTP_PORT"
echo "Freed HTTP port $requested_http by stopping host nginx service" fi
else
HTTP_PORT_VALUE="$(find_free_port 8080)" if [ -n "$requested_ports" ]; then
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE" 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 fi
else else
HTTP_PORT_VALUE="$requested_http" HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
fi HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
requested_https="" if port_in_use "$HTTP_PORT_VALUE"; then
if [ -f "$ENV_FILE" ]; then HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
fi HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
if [ -z "$requested_https" ]; then
requested_https="443"
fi
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 fi
fi
}
HTTPS_PORT_VALUE="$(find_free_port 8443)" ensure_min_docker_disk_space() {
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE" local docker_root available_kb available_mb
else
HTTPS_PORT_VALUE="$requested_https" 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 fi
} }
@@ -543,14 +508,44 @@ write_env_file() {
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_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_IMAGE=$APP_IMAGE_VALUE
EOF 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", "2", "--timeout", "30", "--graceful-timeout", "20", "--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
read -r -a ports_array <<<"$HTTP_PORTS_VALUE"
if [ "${#ports_array[@]}" -gt 1 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports_array[@]}"; do
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
done
fi
fi
}
verify_stack_health() { verify_stack_health() {
local compose_args running_services retry_count=0 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 # Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do while [[ $retry_count -lt 2 ]]; do
@@ -558,10 +553,10 @@ verify_stack_health() {
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \ 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 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 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." echo "Health check passed."
return 0 return 0
fi fi
@@ -574,8 +569,8 @@ verify_stack_health() {
if [[ $retry_count -eq 0 ]]; then if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..." echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true 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
docker compose "${compose_args[@]}" restart app nginx mongodb docker compose "${compose_args[@]}" restart app redis mongodb
sleep 3 sleep 3
((retry_count++)) ((retry_count++))
else else
@@ -586,7 +581,7 @@ verify_stack_health() {
# Final failure # Final failure
echo "Error: stack health check failed after restart attempt." echo "Error: stack health check failed after restart attempt."
docker compose "${compose_args[@]}" ps || true 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 return 1
} }
@@ -594,20 +589,32 @@ parse_args "$@"
ensure_runtime_dependencies ensure_runtime_dependencies
setup_boot_autostart_service setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json ensure_runtime_config_json
setup_scheduled_jobs setup_scheduled_jobs
configure_nuitka_mode configure_nuitka_mode
resolve_app_image resolve_app_image
configure_host_ports configure_host_ports
ensure_min_docker_disk_space
ensure_app_image_loaded ensure_app_image_loaded
write_env_file write_env_file
write_runtime_compose_override
echo "Starting Inventarsystem Docker stack (app + mongodb)..." 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 verify_stack_health
echo "Stack started." echo "Stack started."
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE" echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+19 -1
View File
@@ -4,12 +4,30 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose-multitenant.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first." echo "Error: docker command not found. Install Docker first."
exit 1 exit 1
fi fi
echo "Stopping Inventarsystem Docker stack..." echo "Stopping Inventarsystem Docker stack..."
docker compose down docker compose -f "$COMPOSE_FILE" down
echo "Stack stopped." echo "Stack stopped."
+162 -82
View File
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env" ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo" APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$PROJECT_DIR/dist" 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" mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true 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" SUDO="sudo"
fi fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
apt_install() { apt_install() {
$SUDO apt-get update -y $SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
@@ -44,7 +52,7 @@ ensure_runtime_dependencies() {
local missing=() local missing=()
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
missing+=(docker.io) missing+=(docker)
fi fi
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
@@ -64,88 +72,113 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
log_message "Installing missing dependencies: ${missing[*]}" if [ "$IS_ROOT" = "true" ]; then
apt_install "${missing[@]}" 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 fi
if command -v systemctl >/dev/null 2>&1; then if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true systemctl enable --now docker >/dev/null 2>&1 || true
fi fi
} }
ensure_tls_certificates() { ensure_min_root_disk_space() {
local cert_dir cert_path key_path cn local available_kb available_mb
cert_dir="$PROJECT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
mkdir -p "$cert_dir" if ! command -v df >/dev/null 2>&1; then
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
return 0 return 0
fi fi
cn="${TLS_CN:-localhost}" available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
log_message "No TLS certificates found. Generating self-signed certificate for CN=$cn" if [ -z "$available_kb" ]; then
return 0
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"
fi fi
if [ ! -f "$config_path" ]; then available_mb=$((available_kb / 1024))
cat > "$config_path" <<'EOF' if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
server { log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
listen 80; log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
server_name _; log_message "Free disk space and rerun update."
return 301 https://$host$request_uri; exit 1
fi
} }
server { cleanup_old_dist_artifacts() {
listen 443 ssl; local keep_count
server_name _; keep_count="$DIST_KEEP_COUNT"
ssl_certificate /etc/nginx/certs/inventarsystem.crt; if [ ! -d "$DIST_DIR" ]; then
ssl_certificate_key /etc/nginx/certs/inventarsystem.key; return 0
fi
client_max_body_size 50M; if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
keep_count=2
fi
location / { 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}')
proxy_pass http://app:8000; if [ "${#archives[@]}" -le "$keep_count" ]; then
proxy_http_version 1.1; return 0
proxy_set_header Host $host; fi
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; local index old_archive deleted=0
location = /50x.html { for (( index=keep_count; index<${#archives[@]}; index++ )); do
default_type text/html; old_archive="${archives[$index]}"
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>'; 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)
--singletenant Use docker-compose.yml
-h, --help Show this help message
EOF 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
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help)
usage
exit 0
;;
*)
log_message "ERROR: Unknown option: $1"
usage
exit 2
;;
esac
done
} }
archive_logs() { archive_logs() {
@@ -311,9 +344,7 @@ download_and_extract_bundle() {
tar -xzf "$archive" -C "$tmp_dir" tar -xzf "$archive" -C "$tmp_dir"
# The bundle must contain docker deployment files only. # 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-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/start.sh" "$PROJECT_DIR/start.sh"
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh" cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
@@ -325,25 +356,51 @@ download_and_extract_bundle() {
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh" cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
fi 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/init-admin.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 if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json" cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
log_message "Installed default config.json from release bundle" log_message "Installed default config.json from release bundle"
fi 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/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
} }
deploy() { deploy() {
local tag="$1" local tag="$1"
local meta_file="$2" local meta_file="$2"
local app_image="${APP_IMAGE_REPO}:${tag}" 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" cd "$PROJECT_DIR"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=$app_image INVENTAR_APP_IMAGE=$app_image
EOF EOF
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
@@ -362,27 +419,27 @@ EOF
fi fi
fi fi
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1 docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app 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" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
} }
verify_stack_health() { verify_stack_health() {
local compose_args running_services local compose_args running_services
local https_port local http_port
compose_args=(--env-file "$ENV_FILE") compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')" http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$https_port" ]; then if [ -z "$http_port" ]; then
https_port="443" http_port="10000"
fi fi
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \ 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 printf '%s\n' "$running_services" | grep -Fxq mongodb; then
# Primary check: HTTP endpoint responds (most reliable) # Primary check: health endpoint responds (most reliable)
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
return 0 return 0
fi fi
fi fi
@@ -390,20 +447,41 @@ verify_stack_health() {
done done
docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true 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 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() { main() {
parse_args "$@"
cleanup_server_space
ensure_runtime_dependencies ensure_runtime_dependencies
ensure_tls_certificates
ensure_nginx_config_mount_source
require_cmd curl require_cmd curl
require_cmd tar require_cmd tar
require_cmd docker require_cmd docker
require_cmd python3 require_cmd python3
ensure_min_root_disk_space
archive_logs archive_logs
create_backup create_backup
@@ -491,6 +569,8 @@ main() {
fi fi
echo "$latest_tag" > "$STATE_FILE" echo "$latest_tag" > "$STATE_FILE"
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag" log_message "Update completed successfully to release $latest_tag"
} }