Compare commits

...

18 Commits

Author SHA1 Message Date
Aiirondev_dev 31de22d460 Add detailed explanation of multi-tenant architecture and operational guidelines in the documentation 2026-04-20 15:55:50 +02:00
Aiirondev_dev 96f918828c Refactor tenant management script to use MongoClient directly for database operations and add documentation for multi-tenant architecture 2026-04-20 15:48:46 +02:00
Aiirondev_dev eb912c3a73 Add tenant management container and update management script for improved tenant operations 2026-04-20 15:36:02 +02:00
Aiirondev_dev 3044b36e9f Add tenant management script for zero-downtime operations and database handling 2026-04-20 15:31:46 +02:00
Aiirondev_dev e464628ab7 Enhance admin content layout with responsive design adjustments and improved styling for item cards 2026-04-20 14:34:17 +02:00
Aiirondev_dev a5a6f89f18 Add predefined filters and update cost labels to include currency symbol 2026-04-20 14:10:23 +02:00
Aiirondev_dev 6835ee87a7 Update modal button class and increase z-index for improved visibility 2026-04-20 09:40:08 +02:00
Aiirondev_dev 3a9d45327a Improve mobile layout with viewport adjustments, scrolling enhancements, and refined modal styling 2026-04-20 09:31:11 +02:00
Aiirondev_dev bfbc8b6796 Refactor color styles to use CSS variables for improved consistency and maintainability 2026-04-19 23:35:45 +02:00
Aiirondev_dev 2f6ab9c3fb Enhance navbar styles with improved responsiveness and visual effects 2026-04-19 23:13:57 +02:00
Aiirondev_dev 7a4c3b82c0 Enhance dark mode support and improve mobile layout with theme-aware styles and optimizations 2026-04-19 21:54:00 +02:00
Aiirondev_dev 1d6c722cd3 Implement dynamic page size for mobile devices and optimize rendering with content visibility 2026-04-19 21:43:17 +02:00
Aiirondev_dev 3e48aa23cc Refactor scroll distance calculation and prefetch logic for mobile layout in main and admin templates 2026-04-19 21:36:41 +02:00
Aiirondev_dev e2f8da213a Remove manifest and service worker routes; update base.html to reflect changes 2026-04-19 21:15:40 +02:00
Aiirondev_dev 91fc5cae21 Add routes to serve manifest.json and service worker; update manifest link in base.html 2026-04-19 20:06:11 +02:00
Aiirondev_dev b47fcdf804 Refactor manifest.json for Ausleihsystem; add service worker for caching and offline support 2026-04-19 19:58:26 +02:00
Aiirondev_dev 92bea9a87e Remove student account fields and related logic from registration process 2026-04-19 19:38:46 +02:00
Aiirondev_dev 84ac9e812a Enhance get_current_status logic to maintain 'completed' status and add TTL for push notifications 2026-04-19 16:57:35 +02:00
21 changed files with 839 additions and 388 deletions
View File
+41
View File
@@ -452,3 +452,44 @@ Für Support: Siehe Legal/LICENSE
---
**Version**: 1.0 | **Letzte Aktualisierung**: 2026-04-17 | **Kompatibilität**: Python 3.11+, MongoDB 7.0+, Redis 7+
## Tenant Management Operationen (manage-tenant.sh)
Um einzelne Tenants im Multi-Tenant-Umfeld im laufenden Betrieb und ohne globale Downtime zu verwalten, kann das neue CLI-Skript `manage-tenant.sh` verwendet werden.
### 1. Neuen Tenant hinzufügen
Initialisiert die MongoDB-Datenbankstruktur isoliert für einen neuen Tenant und legt initiale Admin-Zugangsdaten an.
```bash
./manage-tenant.sh add <tenant_id>
```
### 2. Bestimmten Tenant neu starten (Soft-Restart)
Erzwingt sofortigen Logout und einen Cache/Session-Reset für die Nutzer *eines spezifischen* Tenants, ohne andere laufende Instanzen zu beeinträchtigen. Ideal bei Konfigurationsänderungen oder feststeckenden Sessions.
```bash
./manage-tenant.sh restart-tenant <tenant_id>
```
### 3. Tenant sicher entfernen
Löscht die dedizierte MongoDB-Datenbank des gewählten Tenants vollständig (erfordert Bestätigung).
```bash
./manage-tenant.sh remove <tenant_id>
```
### 4. Globale Operationen
```bash
# Zeigt alle aktiven isolierten Tenant-Datenbanken an
./manage-tenant.sh list
# Führt einen Zero-Downtime Rolling-Restart aller Application-Container durch
./manage-tenant.sh restart-all
```
---
## Aktuelle UI- & Funktionsoptimierungen (Release April 2026)
Neben der Docker-Auslagerung wurden spezifische Caching-, Parsing-, und DOM-Tricks integriert, die das Setup weiter entschlacken:
* **DOM Array Slicing für Bilder:** Bei großen Beständen (hunderte Artikel) rendert der Client im Listen/Kachel-Modus künftig nur noch das primäre Bild (`slice(0, 1)`), was den DOM-Memory-Footprint drastisch reduziert und das Einfrieren von Browsern verhindert.
* **Auto-Ingestion von Excel-Filtern:** Der Excel-Importer prüft nun dynamisch neue `categories/filter`, die noch nicht in der Datenbank existieren, und speichert sie direkt in die MongoDB `filter_presets`-Kollektion (Zero-Config für Administratoren).
* **Responsive UI Synchronisierung:** Die Standardansicht (`main.html`) der Smartphones wurde CSS-technisch exakt an das skalierungsfähigere Profil der Admin-Mobile-Ansicht (`main_admin.html`) angeglichen.
+164
View File
@@ -0,0 +1,164 @@
# 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.
---
1. Das Kernkonzept: Wie funktioniert dein Multi-Tenant-System?
Du betreibst keinen separaten Docker-Container für jeden einzelnen Kunden (Tenant). Das wäre extrem speicherintensiv und schwer zu warten. Stattdessen nutzt du ein „Database-per-Tenant“ & „Shared-App“ Modell:
Der App-Pool: In Docker laufen z.B. 3 identische app-Container, die sich die Arbeit teilen (Load-Balancing).
Das Routing (Nginx): Ein Nutzer ruft https://schule1.deinedomain.de auf. Nginx leitet die Anfrage an irgendeinen der freien app-Container weiter.
Die Tenant-Weiche (Python): Deine Python-App greift die Subdomain (schule1) aus der URL ab und lenkt alle Datenbankabfragen dieses Nutzers ausschließlich auf die MongoDB-Datenbank inventar_schule1.
Das Ergebnis: 100% isolierte Daten für jeden Mandanten, aber maximale Ressourceneffizienz auf dem Server.
2. Der Praxis-Ablauf: Einen neuen Kunden anlegen
Um eine neue Schule oder einen neuen Kunden mit eigener Subdomain an den Start zu bringen, brauchst du nur zwei Schritte:
DNS einrichten (Domain-Ebene):
Erstelle bei deinem Domain-Provider einen A-Record oder CNAME für die Subdomain (z.B. schule1), der auf die IP deines Servers zeigt.
Pro-Tipp: Richte dir einen Wildcard-Record (*.deinedomain.de) ein. Dann musst du bei neuen Kunden nie wieder in die DNS-Einstellungen gehen. Jede fiktive Subdomain zeigt dann automatisch auf deinen Server!
Datenbank initialisieren (Server-Ebene):
Führe deinen Wrapper auf dem Server aus, um dem Kunden eine Datenbank und einen initialen Admin-Nutzer zu generieren:
Das System ist nun für diesen Kunden unter schule1.deinedomain.de voll einsatzbereit.
3. Performance anpassen (Skalieren statt Neustarten)
Wenn du plötzlich 20 neue Kunden hast und der Server langsamer wird, musst du keine neuen Instanzen für genau diese Kunden bauen. Du erhöhst einfach die allgemeine Arbeitskraft deines Docker-Compose-Netzwerks:
Nginx bemerkt sofort, dass es nun 5 statt vorher z.B. 2 Container gibt, und verteilt die Last aller aktiven Subdomains dynamisch auf alle 5 Container. Es gibt dabei keine Downtime.
4. Nützliche Zusatz-Infos & Pro-Tipps
Zero-Downtime Soft-Restarts (Session Invalidation):
Manchmal hängt ein Kunde fest oder du hast configs geändert. Anstatt den ganzen Server (und alle Kunden) neu zu starten, nutzt du .run-tenant-cmd.sh restart-tenant schule1. Das leert nur den Redis-Cache und die Sessions dieses einen Kunden. Seine Nutzer müssen sich neu einloggen, alle anderen Mandanten merken davon nichts.
Datensicherheit / Backups:
Da jeder Tenant eine eigene Datenbank hat (inventar_schule1, inventar_schule2), kannst du im Fall eines Datenverlusts (z.B. ein Lehrer löscht versehentlich das halbe Inventar) das Backup nur für diesen einen Kunden wiederherstellen (mongorestore --db inventar_schule1 ...), ohne die Bestandsdaten der anderen 50 Schulen zu überschreiben.
SSL-Zertifikate:
Damit HTTPS für alle Subdomains automatisch funktioniert, generiere beim Setup unbedingt ein Wildcard-Zertifikat via Let's Encrypt (z.B. certbot certonly --manual -d "*.deinedomain.de"). Sonst zeigt der Browser bei einem neu angelegten Tenant eine Sicherheitswarnung, bis du das Zertifikat manuell erneuerst.
Kostenersparnis:
Da alle Container geteilt werden, kannst du auf einem Standard 4GB RAM / 2 vCPU VPS (ca. 5-10 Euro im Monat) problemlos 10-20 verschiedene Schulen parallel hosten, was dieses Architekturmodell extrem skalierbar und günstig macht.
+35 -20
View File
@@ -2063,6 +2063,11 @@ def _upload_excel_items(scope='inventory'):
validation_warnings = []
reserved_codes = set()
known_locations = set(it.get_predefined_locations())
known_filters = {
1: set(it.get_predefined_filter_values(1)),
2: set(it.get_predefined_filter_values(2)),
3: set(it.get_predefined_filter_values(3))
}
def is_code_available(candidate_code):
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
@@ -2114,8 +2119,19 @@ def _upload_excel_items(scope='inventory'):
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
for f in filter1:
if f and f not in known_filters[1]:
validation_warnings.append((row_number, f'Kategorie 1 "{f}" wird neu angelegt'))
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
for f in filter2:
if f and f not in known_filters[2]:
validation_warnings.append((row_number, f'Kategorie 2 "{f}" wird neu angelegt'))
filter3 = _excel_list(val('filter3'))
for f in filter3:
if f and f not in known_filters[3]:
validation_warnings.append((row_number, f'Kategorie 3 "{f}" wird neu angelegt'))
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
raw_year = val('anschaffungsjahr')
@@ -2217,6 +2233,21 @@ def _upload_excel_items(scope='inventory'):
it.add_predefined_location(row['ort'])
known_locations.add(row['ort'])
for f in row.get('filter1', []):
if f and f not in known_filters[1]:
it.add_predefined_filter_value(1, f)
known_filters[1].add(f)
for f in row.get('filter2', []):
if f and f not in known_filters[2]:
it.add_predefined_filter_value(2, f)
known_filters[2].add(f)
for f in row.get('filter3', []):
if f and f not in known_filters[3]:
it.add_predefined_filter_value(3, f)
known_filters[3].add(f)
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
row_created_ids = []
@@ -7060,9 +7091,7 @@ def register():
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
is_student = bool(request.form.get('is_student')) if cfg.STUDENT_CARDS_MODULE_ENABLED else False
student_card_id = us.normalize_student_card_id(request.form.get('student_card_id')) if cfg.STUDENT_CARDS_MODULE_ENABLED else ''
max_borrow_days_raw = request.form.get('max_borrow_days') if cfg.STUDENT_CARDS_MODULE_ENABLED else None
if not username or not password or not name or not last_name:
flash('Bitte füllen Sie alle Felder aus', 'error')
return redirect(url_for('register'))
@@ -7081,28 +7110,14 @@ def register():
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
max_borrow_days = None
if is_student:
if not student_card_id:
flash('Für Schüler muss eine Ausweis-ID angegeben werden.', 'error')
return redirect(url_for('register'))
if us.student_card_exists(student_card_id):
flash('Die Ausweis-ID ist bereits vergeben.', 'error')
return redirect(url_for('register'))
try:
max_borrow_days = int(max_borrow_days_raw) if max_borrow_days_raw else cfg.STUDENT_DEFAULT_BORROW_DAYS
except (TypeError, ValueError):
max_borrow_days = cfg.STUDENT_DEFAULT_BORROW_DAYS
max_borrow_days = max(1, min(max_borrow_days, cfg.STUDENT_MAX_BORROW_DAYS))
us.add_user(
username,
password,
name,
last_name,
is_student=is_student,
student_card_id=student_card_id if is_student else None,
max_borrow_days=max_borrow_days,
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset=permission_preset,
action_permissions=action_permissions,
page_permissions=page_permissions,
+4 -4
View File
@@ -71,6 +71,10 @@ def get_current_status(ausleihung, log_changes=False, user=None):
# Bei stornierten Ausleihungen bleibt der Status immer storniert
if original_status == 'cancelled':
return 'cancelled'
# Wenn die Ausleihung bereits abgeschlossen ist, bleibt sie es
if original_status == 'completed':
return 'completed'
current_time = datetime.datetime.now()
start_time = ausleihung.get('Start')
@@ -79,10 +83,6 @@ def get_current_status(ausleihung, log_changes=False, user=None):
# Wenn kein Startdatum vorhanden ist, Status auf 'planned' setzen
if not start_time:
new_status = 'planned'
# Wenn die Ausleihung als 'completed' markiert wurde und ein Enddatum hat,
# bleibt sie bei 'completed'
elif original_status == 'completed' and end_time:
new_status = 'completed'
# Wenn die aktuelle Zeit vor dem Startdatum liegt, ist die Ausleihung geplant
elif current_time < start_time:
# DEBUG: Log info wenn Booking noch lange in der Zukunft ist
+1
View File
@@ -0,0 +1 @@
[2026-04-19 20:20:39,502] ERROR in app: Error during scheduler shutdown: name '__file__' is not defined
+2 -1
View File
@@ -324,7 +324,8 @@ def _send_web_push_notification(subscription, payload):
data=json.dumps(payload),
vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims={'sub': VAPID_SUBJECT},
timeout=10
timeout=10,
ttl=3600 # Notification expires after 1 hour if device is offline
)
return True
+64 -33
View File
@@ -21,6 +21,28 @@
--ui-radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--ui-bg: #0f172a;
--ui-bg-accent: #1e293b;
--ui-surface: #1e293b;
--ui-surface-soft: #25314a;
--ui-border: #334155;
--ui-text: #f1f5f9;
--ui-text-muted: #94a3b8;
--ui-title: #ffffff;
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
}
/* Make inputs visible in dark mode */
input, select, textarea {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
}
body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
color: var(--ui-text);
@@ -92,7 +114,7 @@ span {
.card-header,
.modal-header,
thead th {
background: linear-gradient(180deg, #f7fafd, #edf3f8) !important;
background: var(--ui-surface-soft) !important;
border-bottom: 1px solid var(--ui-border) !important;
color: var(--ui-title) !important;
}
@@ -107,10 +129,10 @@ input[type="email"],
input[type="date"],
select {
border-radius: 10px !important;
border: 1px solid #cad5e0 !important;
border: 1px solid var(--ui-border) !important;
min-height: 40px;
color: var(--ui-text) !important;
background-color: #fff !important;
background-color: var(--ui-surface) !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@@ -131,11 +153,13 @@ select:focus {
.table th,
.table td {
vertical-align: middle;
border-color: #dee6ee !important;
border-color: var(--ui-border) !important;
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
.table-striped > tbody > tr:nth-of-type(odd) {
background-color: #f8fbff;
background-color: var(--ui-surface-soft) !important;
}
.alert {
@@ -144,8 +168,10 @@ select:focus {
}
.navbar {
border-bottom: 1px solid #273341;
border-bottom: 1px solid var(--ui-border);
box-shadow: none;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.navbar-brand {
@@ -164,21 +190,13 @@ select:focus {
}
}
/* Edit and generate buttons with black text for better visibility */
.edit-button {
color: black !important; /* Override any existing color */
}
.duplicate-button {
color: black !important; /* Override any existing color */
}
.generate-qr-button {
color: black !important; /* Override any existing color */
/* Edit and generate buttons with theme-aware text for better visibility */
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-text) !important; /* Override any existing color */
}
.ausleihen {
color: black !important; /* Override any existing color */
/* Color handled securely below */
}
/* Modal image display fix */
@@ -245,12 +263,12 @@ select:focus {
.view-toggle-btn {
width: 38px;
height: 38px;
border: 1px solid #c7ced6;
border: 1px solid var(--ui-border);
border-radius: 50%;
background: #ffffff;
background: var(--ui-surface);
font-size: 1.15rem;
cursor: pointer;
color: #2f3e4e;
color: var(--ui-text);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -259,8 +277,8 @@ select:focus {
.view-toggle-btn:hover {
transform: translateY(-1px);
border-color: #8fa2b7;
background: #f8fbff;
border-color: var(--ui-border);
background: var(--ui-surface-soft);
box-shadow: 0 3px 8px rgba(33, 37, 41, 0.12);
}
@@ -269,9 +287,9 @@ select:focus {
}
.view-toggle-btn[aria-pressed='true'] {
background: #e9f2ff;
border-color: #7ea1c8;
color: #1f4f7a;
background: var(--module-primary-color);
border-color: var(--module-primary-color);
color: white;
}
.table-view-header {
@@ -288,7 +306,7 @@ body.table-view .table-view-header {
border: 0;
border-radius: 10px;
background: transparent;
color: #3f4e5d;
color: var(--ui-text-muted);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
@@ -307,9 +325,9 @@ body.table-view .item-card {
max-width: none;
margin: 0 0 10px 0;
padding: 12px 14px;
border: 1px solid #e1e6ed;
border: 1px solid var(--ui-border);
border-radius: 10px;
background: #ffffff;
background: var(--ui-surface);
box-shadow: none;
scroll-snap-align: none;
}
@@ -335,7 +353,7 @@ body.table-view .item-card .card-content .item-name-cell,
body.table-view .item-card .card-content .item-col-name {
font-size: 0.98rem;
font-weight: 700;
color: #1f2d3d;
color: var(--ui-title);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -352,7 +370,7 @@ body.table-view .item-card .card-content .item-col-filter3,
body.table-view .item-card .card-content .item-code-cell,
body.table-view .item-card .card-content .item-col-code,
body.table-view .item-card .card-content .item-col-count {
color: #445465;
color: var(--ui-text-muted);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
@@ -369,7 +387,7 @@ body.table-view .item-card .damage-badge {
body.table-view .item-card .actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #d9e0e8;
border-top: 1px dashed var(--ui-border);
}
@media (max-width: 900px) {
@@ -805,4 +823,17 @@ body.table-view .item-card .actions {
padding: 7px 12px !important;
font-size: 0.84rem !important;
}
}
}
/* iOS and Mobile optimization fixes */
html, body {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
.scrollable-container, .items-container {
-webkit-overflow-scrolling: touch;
}
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.fixed-bottom, .modal-footer {
padding-bottom: env(safe-area-inset-bottom);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+9 -120
View File
@@ -1,134 +1,23 @@
{
"name": "Inventarsystem",
"short_name": "Inventar",
"description": "Verwaltungssystem für Schulinventare mit Ausleihmanagement",
"name": "Ausleihsystem",
"short_name": "Ausleihe",
"description": "System zur Verwaltung von Ausleihen",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#007bff",
"dir": "ltr",
"lang": "de-DE",
"prefer_related_applications": false,
"related_applications": [],
"theme_color": "#1f2937",
"icons": [
{
"src": "/static/img/logo-72x72.png",
"sizes": "72x72",
"src": "/static/img/icon-192.png",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
},
{
"src": "/static/img/logo-384x384.png",
"sizes": "384x384",
"src": "/static/img/icon-512.png",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-192x192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/img/logo-512x512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/static/img/screenshot-540x720.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/static/img/screenshot-1280x720.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"categories": ["education", "productivity"],
"shortcuts": [
{
"name": "Meine Ausleihungen",
"short_name": "Ausleihungen",
"description": "Zeige meine aktuellen Ausleihungen",
"url": "/my_borrowed_items",
"icons": [
{
"src": "/static/img/shortcut-96x96.png",
"sizes": "96x96"
}
]
},
{
"name": "Kalender",
"short_name": "Kalender",
"description": "Kalender für Ausleihplanungen",
"url": "/terminplan",
"icons": [
{
"src": "/static/img/shortcut-96x96.png",
"sizes": "96x96"
}
]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*", "video/*"]
}
]
}
}
]
}
+18
View File
@@ -0,0 +1,18 @@
const CACHE_NAME = 'ausleihe-cache-v1';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event) => {
// Einfacher Fallback aufs Netzwerk (Offline-Seite könnte hier ergänzt werden)
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request);
})
);
});
+93 -24
View File
@@ -10,10 +10,12 @@
<html lang="de" data-module="{{ CURRENT_MODULE }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover">
<meta name="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="theme-color" content="#2c3e50" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1a252f" media="(prefers-color-scheme: dark)">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{% block title %}Inventarsystem{% endblock %}</title>
{% block head %}
@@ -102,6 +104,9 @@
--module-accent-color: #3498db;
--module-accent-light: rgba(52, 152, 219, 0.1);
--module-brand-icon: "📦";
--page-bg-gradient: linear-gradient(180deg, #f2f5f8, #e7edf4);
--page-bg-overlay1: rgba(86, 145, 200, 0.08);
--page-bg-overlay2: rgba(52, 98, 150, 0.09);
}
html[data-module="library"] {
@@ -112,17 +117,35 @@
--module-brand-icon: "📚";
}
@media (prefers-color-scheme: dark) {
:root {
--module-primary-color: #1a252f;
--module-primary-light: #2c3e50;
--module-accent-color: #2980b9;
--module-brand-icon: "📦";
--page-bg-gradient: linear-gradient(180deg, #0f172a, #1e293b);
--page-bg-overlay1: rgba(56, 189, 248, 0.05);
--page-bg-overlay2: rgba(59, 130, 246, 0.06);
}
html[data-module="library"] {
--module-primary-color: #4a354d;
--module-primary-light: #614666;
--module-accent-color: #8e44ad;
}
}
/* Global styles */
body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
background:
radial-gradient(1200px 500px at 10% -10%, rgba(86, 145, 200, 0.08), transparent 55%),
radial-gradient(900px 420px at 95% 0%, rgba(52, 98, 150, 0.09), transparent 52%),
linear-gradient(180deg, #f2f5f8, #e7edf4);
radial-gradient(1200px 500px at 10% -10%, var(--page-bg-overlay1), transparent 55%),
radial-gradient(900px 420px at 95% 0%, var(--page-bg-overlay2), transparent 52%),
var(--page-bg-gradient);
margin: 0;
padding: 0;
padding-bottom: 30px;
color: #1f2d3d;
padding-bottom: auto; /* Fallback for dynamic calculation */
padding-bottom: env(safe-area-inset-bottom, 30px);
color: var(--ui-text, #1f2d3d);
}
.container {
@@ -135,11 +158,13 @@
background-color: var(--module-primary-color) !important;
position: sticky;
top: 0;
padding-top: env(safe-area-inset-top, 0); /* iOS Safe Area */
z-index: 1900;
}
.navbar .container-fluid {
gap: 10px;
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */
}
.navbar-brand {
@@ -351,18 +376,19 @@
/* Dropdown menus - consistent with system design */
.dropdown-menu {
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border: 1px solid #dee2e6;
box-shadow: var(--ui-shadow-md);
border: 1px solid var(--ui-border);
border-radius: 8px;
padding: 8px 0;
min-width: 200px;
margin-top: 5px;
background-color: var(--ui-surface);
}
.dropdown-item {
padding: 10px 16px;
font-size: 0.9rem;
color: #495057;
color: var(--ui-text);
transition: all 0.2s ease;
border: none;
background: none;
@@ -370,8 +396,8 @@
.dropdown-item:hover,
.dropdown-item:focus {
background-color: #f8f9fa;
color: #007bff;
background-color: var(--ui-surface-soft);
color: var(--module-accent-color);
transform: translateX(2px);
}
@@ -434,12 +460,16 @@
}
.nav-item.dropdown .dropdown-toggle {
padding: 8px 12px;
padding: 12px 12px;
min-height: 48px;
}
.navbar-toggler {
padding: 0.5rem 0.65rem;
padding: 0.6rem 0.8rem;
border-width: 2px;
min-height: 48px;
min-width: 48px;
margin-top: 4px;
}
.function-search-wrap {
@@ -573,17 +603,38 @@
margin-left: auto;
flex: 0 0 auto;
align-self: flex-start;
min-width: 48px;
min-height: 48px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.navbar-toggler:active,
.navbar-toggler:focus {
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.navbar-collapse {
order: 4;
width: 100%;
margin-top: 8px;
padding: 10px 10px 6px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
margin-top: 12px;
padding: 12px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.85);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
max-height: calc(100vh - 90px); /* fallback */
max-height: calc(100dvh - 90px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.navbar-collapse.show,
@@ -595,7 +646,7 @@
width: 100%;
align-items: stretch;
gap: 8px;
padding-top: 2px;
padding-top: 4px;
}
.navbar-nav .nav-item {
@@ -605,11 +656,12 @@
.navbar-nav .nav-link {
width: 100%;
justify-content: flex-start;
min-height: 46px;
padding: 0.8rem 0.95rem;
min-height: 48px;
padding: 0.9rem 1.1rem;
margin-bottom: 2px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.navbar-nav .nav-link.nav-active,
@@ -632,6 +684,23 @@
margin-left: 0;
margin-top: 6px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08); /* Match nav-link slightly */
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.navbar-nav .dropdown-item {
color: rgba(255, 255, 255, 0.9);
padding: 12px 16px;
border-radius: 8px;
margin: 2px 8px;
width: calc(100% - 16px);
}
.navbar-nav .dropdown-item:hover,
.navbar-nav .dropdown-item:focus {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.module-selector-bar {
+182 -87
View File
@@ -6,7 +6,7 @@
/* Calendar styles for detailed view */
.calendar-wrapper {
background: #fff;
background: var(--ui-surface);
border: 1px solid #dbe3ee;
border-radius: 12px;
overflow: hidden;
@@ -19,7 +19,7 @@
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e7edf5;
border-bottom: 1px solid var(--ui-border);
background: #f8fafc;
}
@@ -28,8 +28,8 @@
height: 34px;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #fff;
color: #334155;
background: var(--ui-surface);
color: var(--ui-text);
font-weight: 700;
line-height: 1;
cursor: pointer;
@@ -60,7 +60,7 @@
align-items: center;
justify-content: center;
border-radius: 8px;
color: #334155;
color: var(--ui-text);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
@@ -109,7 +109,7 @@
}
.cal-cell.selected.has-booking::after {
background: #fff;
background: var(--ui-surface);
}
.calendar-day-details {
@@ -138,7 +138,7 @@
}
.cal-booking-item {
background: #fff;
background: var(--ui-surface);
border: 1px solid #dbe3ee;
border-left: 3px solid #2563eb;
border-radius: 10px;
@@ -173,7 +173,7 @@
.cal-booking-period {
margin-left: 8px;
color: #475569;
color: var(--ui-text);
font-size: 0.85rem;
font-weight: 600;
}
@@ -225,14 +225,14 @@
.filter-option-group {
margin-bottom: 12px;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--ui-border);
padding-bottom: 8px;
}
.filter-group-header {
font-weight: bold;
margin-bottom: 5px;
color: #495057;
color: var(--ui-text-muted);
font-size: 0.9rem;
}
@@ -253,7 +253,7 @@
}
.filter-tag-group {
color: #6c757d;
color: var(--ui-text-muted);
font-size: 0.8em;
font-style: italic;
margin-right: 2px;
@@ -262,7 +262,7 @@
.filter-tag-remove {
background: none;
border: none;
color: #6c757d;
color: var(--ui-text-muted);
margin-left: 5px;
cursor: pointer;
font-size: 1rem;
@@ -296,7 +296,7 @@
#code-search {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 4px;
}
@@ -321,7 +321,7 @@
border: 1px solid #dc3545 !important;
}
</style>
<div class="container">
<div class="container admin-content-container">
<!-- Conflict warning banner (populated by JS) -->
<div id="conflict-banner" style="display:none; background:#fff3cd; border:1px solid #ffc107; border-left:4px solid #fd7e14; color:#856404; padding:12px 16px; border-radius:4px; margin-bottom:16px; position:relative;">
<strong>⚠ Reservierungskonflikt</strong>
@@ -724,7 +724,9 @@
// Add this line to define allItems globally
let allItems = [];
const MAIN_ITEMS_PAGE_SIZE = 120;
// Dynamic page size: scan viewport/screen size to only load the exact amount needed.
const isMobileDevice = window.matchMedia('(max-width: 768px)').matches;
const MAIN_ITEMS_PAGE_SIZE = isMobileDevice ? 20 : 120;
let mainItemsNextOffset = 0;
let mainItemsHasMore = false;
let mainItemsLoadingMore = false;
@@ -781,10 +783,10 @@
styles.flexDirection === 'column';
const distanceToEnd = isMobileLayout
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
? Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - (window.scrollY + window.innerHeight)
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = isMobileLayout
? Math.max(220, itemsContainer.clientHeight * 0.9)
? window.innerHeight * 1.5
: Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
@@ -902,7 +904,7 @@
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
const imagesHtml = item.Images ? item.Images.map((file, index) => {
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((file, index) => {
// Get file with correct case handling
const image = file;
@@ -1003,14 +1005,14 @@
card.innerHTML = `
<div class="card-content" data-item-id="${item._id}">
<h3 class="item-name-cell">${item.Name}</h3>
<p class="item-ort-cell"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-filter1-cell"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-filter2-cell"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-filter3-cell"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
<p class="item-code-cell"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-count-cell"><strong>Anzahl:</strong> ${groupedCount}</p>
${isGroupedItem ? `<p class="item-count-cell"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
<h3 class="item-col-name">${item.Name}</h3>
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
<div class="image-container">
${imagesHtml}
</div>
@@ -1167,6 +1169,7 @@
if (!mainItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
window.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
mainItemsScrollPrefetchBound = true;
}
@@ -1188,7 +1191,7 @@
updateMainItemsLoadingIndicator();
});
}, {
root: itemsContainer,
root: isMobileLayout ? null : itemsContainer,
threshold: 0.15,
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
});
@@ -1466,7 +1469,7 @@
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return `
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
<div style="border:1px solid var(--ui-border);border-radius:8px;padding:10px;background:var(--ui-surface);display:grid;gap:6px;">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
@@ -1628,7 +1631,7 @@
<div class="detail-group">
<div class="detail-label">Anschaffungskosten:</div>
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
</div>
<div class="detail-group full-width">
@@ -1716,7 +1719,7 @@
: `<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
<button type="button" class="close-modal secondary-close" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
@@ -2805,7 +2808,7 @@
}
.container {
background-color: #fff;
background-color: var(--ui-surface);
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@@ -2830,8 +2833,8 @@
.filter-group {
position: relative;
min-width: 200px;
background-color: #fff;
border: 1px solid #ddd;
background-color: var(--ui-surface);
border: 1px solid var(--ui-border);
border-radius: 5px;
padding: 10px;
}
@@ -2853,7 +2856,7 @@
.clear-filter {
font-size: 0.8rem;
color: #6c757d;
color: var(--ui-text-muted);
}
.filter-dropdown {
@@ -2865,7 +2868,7 @@
max-height: 300px;
overflow-y: auto;
background-color: white;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 5px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
z-index: 100;
@@ -2900,14 +2903,14 @@
.filter-option-group {
margin-bottom: 12px;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--ui-border);
padding-bottom: 8px;
}
.filter-group-header {
font-weight: bold;
margin-bottom: 5px;
color: #495057;
color: var(--ui-text-muted);
font-size: 0.9rem;
}
@@ -2928,7 +2931,7 @@
}
.filter-tag-group {
color: #6c757d;
color: var(--ui-text-muted);
font-size: 0.8em;
font-style: italic;
margin-right: 2px;
@@ -2937,7 +2940,7 @@
.filter-tag-remove {
background: none;
border: none;
color: #6c757d;
color: var(--ui-text-muted);
margin-left: 5px;
cursor: pointer;
font-size: 1rem;
@@ -2954,7 +2957,7 @@
#code-search {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 4px;
}
@@ -3026,7 +3029,7 @@
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
color: var(--ui-text);
justify-content: center;
align-items: center;
flex-direction: column;
@@ -3083,19 +3086,19 @@
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-color: var(--ui-surface);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm);
padding: 20px;
max-width: 300px;
flex-shrink: 0;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
scroll-snap-align: center;
cursor: pointer;
transition: transform 0.2s;
position: relative;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
@@ -3140,7 +3143,7 @@
.item-modal {
display: none;
position: fixed;
z-index: 1000;
z-index: 2500;
left: 0;
top: 0;
width: 100%;
@@ -3191,7 +3194,7 @@
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 4px;
box-sizing: border-box;
}
@@ -3235,7 +3238,7 @@
}
.cancel-button {
background-color: #6c757d;
background-color: var(--ui-text-muted);
color: white;
}
@@ -3467,6 +3470,19 @@
/* Responsive adjustments */
@media (max-width: 768px) {
/* Mobile viewport and scrolling improvements */
html, body {
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
touch-action: pan-y !important;
height: 100% !important;
}
body {
padding: 0 !important;
margin: 0 !important;
}
.filter-container {
flex-direction: column;
}
@@ -3552,8 +3568,9 @@
display: grid !important;
grid-template-columns: 1fr !important;
gap: 15px !important;
padding: 10px 0 0 !important;
padding: 10px 0 !important;
overflow-x: visible !important;
scroll-snap-type: none !important;
}
.item-card {
@@ -3561,6 +3578,9 @@
margin: 0 !important;
padding: 15px !important;
scroll-snap-align: none !important;
/* Only render when user looks at it (content-visibility virtualizes the DOM) */
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}
.item-card:hover {
@@ -3634,7 +3654,7 @@
display: block !important;
margin-bottom: 8px !important;
font-weight: 600 !important;
color: #333 !important;
color: var(--ui-title) !important;
}
.form-group input, .form-group select, .form-group textarea {
@@ -3705,8 +3725,8 @@
}
/* Element text colors for better visibility */
.edit-button, .duplicate-button, .generate-qr-button, .ausleihen {
color: black !important;
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-title) !important;
}
/* Standardized button styles across the application */
@@ -3867,7 +3887,7 @@
.modal-details { margin: 20px 0; }
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
.detail-group.full-width { flex-direction: column; margin-bottom: 15px; }
.detail-label { font-weight: bold; color: #495057; min-width: 150px; margin-right: 10px; }
.detail-label { font-weight: bold; color: var(--ui-text-muted); min-width: 150px; margin-right: 10px; }
.detail-value { color: #212529; flex: 1; word-wrap: break-word; }
.detail-group.full-width .detail-label { margin-bottom: 5px; }
.detail-group.full-width .detail-value {
@@ -3935,7 +3955,7 @@
/* Calendar Styles - Optimized & Mobile Friendly */
.calendar-wrapper {
background: #fff;
background: var(--ui-surface);
border-radius: 12px;
overflow: hidden;
border: 1px solid #e5e7eb;
@@ -3948,8 +3968,8 @@
justify-content: space-between;
align-items: center;
padding: 15px;
background-color: #fff;
border-bottom: 1px solid #f3f4f6;
background-color: var(--ui-surface);
border-bottom: 1px solid var(--ui-border);
}
.calendar-header button {
@@ -3973,7 +3993,7 @@
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
background-color: #fff;
background-color: var(--ui-surface);
padding: 10px;
gap: 5px;
}
@@ -4035,7 +4055,7 @@
}
.cal-cell.selected.has-booking::after {
background-color: #fff;
background-color: var(--ui-surface);
}
.calendar-day-details {
@@ -4063,7 +4083,7 @@
.cal-booking-item {
padding: 10px;
background: #fff;
background: var(--ui-surface);
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
@@ -4094,6 +4114,7 @@
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
box-sizing: border-box !important;
}
.filter-group {
@@ -4115,7 +4136,7 @@
font-size: 0.95rem !important;
font-weight: 600 !important;
line-height: 1.35 !important;
color: #212529 !important;
color: var(--ui-title) !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
@@ -4153,7 +4174,7 @@
overflow-wrap: break-word !important;
}
body.table-view .item-card .card-content .item-name-cell {
body.table-view .item-card .card-content .item-col-name {
font-size: 0.95rem !important;
}
@@ -4193,7 +4214,7 @@
display: block !important;
margin-right: 0 !important;
margin-bottom: 2px !important;
color: #6c757d !important;
color: var(--ui-text-muted) !important;
font-size: 0.8rem !important;
}
@@ -4213,8 +4234,12 @@
}
.items-container {
padding: 6px 0 !important;
gap: 10px !important;
display: grid !important;
grid-template-columns: 1fr !important;
gap: 15px !important;
padding: 10px 0 !important;
overflow-x: visible !important;
scroll-snap-type: none !important;
}
body.table-view .table-view-header {
@@ -4231,16 +4256,17 @@
}
.item-card {
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
border-bottom: 1px solid #e5e7eb !important;
padding: 12px 0 !important;
max-width: 100% !important;
margin: 0 !important;
padding: 15px !important;
scroll-snap-align: none !important;
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}
.item-card:hover {
transform: none !important;
box-shadow: none !important;
transform: translateY(-2px) !important;
box-shadow: var(--ui-shadow-md) !important;
}
.item-card .image-container {
@@ -4305,13 +4331,12 @@
}
.modal-content {
width: 100% !important;
max-width: 100% !important;
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 80px auto 10px !important;
max-height: 85vh !important;
overflow-y: auto !important;
border-radius: 8px !important;
padding: 16px !important;
}
@@ -4430,7 +4455,7 @@ function openItemQuick(id){
}
.filter-group {
background: #ffffff;
background: var(--ui-surface);
border: 1px solid #cfd8e3;
border-radius: 12px;
padding: 12px;
@@ -4478,7 +4503,7 @@ function openItemQuick(id){
}
.clear-filter {
background: #fff;
background: var(--ui-surface);
color: #495868;
}
@@ -4487,7 +4512,7 @@ function openItemQuick(id){
border: 1px solid #cfd8e3;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(20, 35, 60, 0.12);
background: #ffffff;
background: var(--ui-surface);
padding: 10px;
}
@@ -4573,4 +4598,74 @@ function openItemQuick(id){
}
}
</style>
{% endblock %}
{% endblock %}
<style>
/* Admin/Main Content Container align */
.admin-content-container {
--table-columns: minmax(190px, 2fr) repeat(6, minmax(110px, 1fr));
}
@media screen and (min-width: 769px) and (max-width: 1024px) {
.admin-content-container {
width: 90% !important;
}
.items-container {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 20px !important;
}
}
@media screen and (max-width: 768px) {
.admin-content-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
.item-card .item-image {
width: 100% !important;
height: auto !important;
max-height: 300px !important;
display: block !important;
object-fit: cover !important;
}
.item-card .image-container {
height: 200px !important;
overflow: hidden !important;
position: relative !important;
margin-bottom: 15px !important;
}
.actions {
gap: 8px !important;
border-top: none !important;
padding-top: 8px !important;
flex-direction: column !important;
align-items: stretch !important;
}
.actions form,
.actions button,
.actions a {
width: 100% !important;
}
.items-container, .item-card, .image-container, .item-image {
touch-action: pan-y !important;
-webkit-overflow-scrolling: touch !important;
}
}
@media (max-width: 767px) {
body {
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.admin-content-container {
width: 100%;
max-width: 100%;
margin: 0;
padding: 12px 12px 18px;
box-sizing: border-box;
overflow-x: hidden;
}
}
</style>
+67 -58
View File
@@ -21,7 +21,7 @@
.items-indicator {
text-align: center;
font-size: 0.9rem;
color: #495057;
color: var(--ui-text-muted);
margin: 6px 0 16px;
font-weight: 600;
}
@@ -55,7 +55,7 @@
max-width: 980px;
margin: 0 auto 18px;
border: 1px solid #dbe3ee;
background: #ffffff;
background: var(--ui-surface);
border-radius: 10px;
padding: 14px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
@@ -78,7 +78,7 @@
}
.bulk-delete-summary {
color: #334155;
color: var(--ui-text);
font-size: 0.92rem;
margin-top: 4px;
}
@@ -105,7 +105,7 @@
padding: 9px 12px;
cursor: pointer;
background: #f8fafc;
color: #1e293b;
color: var(--ui-title);
font-weight: 600;
text-align: center;
}
@@ -138,8 +138,8 @@
.filter-group {
position: relative;
min-width: 200px;
background-color: #fff;
border: 1px solid #ddd;
background-color: var(--ui-surface);
border: 1px solid var(--ui-border);
border-radius: 5px;
padding: 10px;
}
@@ -161,7 +161,7 @@
.clear-filter {
font-size: 0.8rem;
color: #6c757d;
color: var(--ui-text-muted);
}
.filter-dropdown {
@@ -173,7 +173,7 @@
max-height: 300px;
overflow-y: auto;
background-color: white;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 5px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
z-index: 100;
@@ -208,14 +208,14 @@
.filter-option-group {
margin-bottom: 12px;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--ui-border);
padding-bottom: 8px;
}
.filter-group-header {
font-weight: bold;
margin-bottom: 5px;
color: #495057;
color: var(--ui-text-muted);
font-size: 0.9rem;
}
@@ -236,7 +236,7 @@
}
.filter-tag-group {
color: #6c757d;
color: var(--ui-text-muted);
font-size: 0.8em;
font-style: italic;
margin-right: 2px;
@@ -245,7 +245,7 @@
.filter-tag-remove {
background: none;
border: none;
color: #6c757d;
color: var(--ui-text-muted);
margin-left: 5px;
cursor: pointer;
font-size: 1rem;
@@ -262,7 +262,7 @@
#code-search {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 4px;
}
@@ -334,7 +334,7 @@
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
color: var(--ui-text);
justify-content: center;
align-items: center;
flex-direction: column;
@@ -391,16 +391,17 @@
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-color: var(--ui-surface);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm);
padding: 20px;
max-width: 300px;
flex-shrink: 0;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
scroll-snap-align: center;
cursor: pointer;
transition: transform 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
@@ -447,7 +448,7 @@
align-items: center;
gap: 6px;
font-size: 0.9rem;
color: #475569;
color: var(--ui-text);
cursor: pointer;
user-select: none;
}
@@ -581,7 +582,7 @@
.item-modal {
display: none;
position: fixed;
z-index: 1000;
z-index: 2500;
left: 0;
top: 0;
width: 100%;
@@ -632,7 +633,7 @@
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 4px;
box-sizing: border-box;
}
@@ -676,7 +677,7 @@
}
.cancel-button {
background-color: #6c757d;
background-color: var(--ui-text-muted);
color: white;
}
@@ -703,7 +704,7 @@
.filter-inputs {
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 5px;
background-color: #f8f9fa;
}
@@ -711,7 +712,7 @@
.filter-inputs h3 {
margin-top: 0;
margin-bottom: 15px;
color: #495057;
color: var(--ui-text-muted);
}
.multi-filter {
@@ -723,7 +724,7 @@
.filter-dropdown-select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border: 1px solid var(--ui-border);
border-radius: 4px;
background-color: white;
}
@@ -1000,16 +1001,16 @@
/* Disabled Button */
.disabled-button {
background-color: #6c757d !important;
border-color: #6c757d !important;
background-color: var(--ui-text-muted) !important;
border-color: var(--ui-text-muted) !important;
color: #fff !important;
cursor: not-allowed !important;
opacity: 0.6;
}
.disabled-button:hover {
background-color: #6c757d !important;
border-color: #6c757d !important;
background-color: var(--ui-text-muted) !important;
border-color: var(--ui-text-muted) !important;
transform: none !important;
box-shadow: none !important;
}
@@ -1032,7 +1033,7 @@
.detail-label {
font-weight: bold;
color: #495057;
color: var(--ui-text-muted);
min-width: 150px;
margin-right: 10px;
}
@@ -1351,7 +1352,7 @@
display: block !important;
margin-bottom: 8px !important;
font-weight: 600 !important;
color: #333 !important;
color: var(--ui-title) !important;
}
.form-group input, .form-group select, .form-group textarea {
@@ -1389,7 +1390,7 @@
/* Tab navigation improvements */
.nav-tabs {
flex-wrap: wrap !important;
border-bottom: 1px solid #dee2e6 !important;
border-bottom: 1px solid var(--ui-border) !important;
}
.nav-tabs .nav-link {
@@ -1605,6 +1606,7 @@
gap: 15px !important;
padding: 10px 0 !important;
overflow-x: visible !important;
scroll-snap-type: none !important;
}
.item-card {
@@ -1612,6 +1614,9 @@
margin: 0 !important;
padding: 15px !important;
scroll-snap-align: none !important;
/* Only render when user looks at it (content-visibility virtualizes the DOM) */
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}
.item-card:hover {
@@ -1685,7 +1690,7 @@
display: block !important;
margin-bottom: 8px !important;
font-weight: 600 !important;
color: #333 !important;
color: var(--ui-title) !important;
}
.form-group input, .form-group select, .form-group textarea {
@@ -1959,8 +1964,8 @@
}
/* Element text colors for better visibility */
.edit-button, .duplicate-button, .generate-qr-button, .ausleihen {
color: black !important;
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-title) !important;
}
/* Standardized button styles across the application */
@@ -2003,7 +2008,7 @@
}
/* Calendar styles for detailed view */
.calendar-wrapper {
background: #fff;
background: var(--ui-surface);
border: 1px solid #dbe3ee;
border-radius: 12px;
overflow: hidden;
@@ -2016,7 +2021,7 @@
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e7edf5;
border-bottom: 1px solid var(--ui-border);
background: #f8fafc;
}
@@ -2025,8 +2030,8 @@
height: 34px;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #fff;
color: #334155;
background: var(--ui-surface);
color: var(--ui-text);
font-weight: 700;
line-height: 1;
cursor: pointer;
@@ -2057,7 +2062,7 @@
align-items: center;
justify-content: center;
border-radius: 8px;
color: #334155;
color: var(--ui-text);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
@@ -2106,7 +2111,7 @@
}
.cal-cell.selected.has-booking::after {
background: #fff;
background: var(--ui-surface);
}
.calendar-day-details {
@@ -2135,7 +2140,7 @@
}
.cal-booking-item {
background: #fff;
background: var(--ui-surface);
border: 1px solid #dbe3ee;
border-left: 3px solid #2563eb;
border-radius: 10px;
@@ -2170,7 +2175,7 @@
.cal-booking-period {
margin-left: 8px;
color: #475569;
color: var(--ui-text);
font-size: 0.85rem;
font-weight: 600;
}
@@ -2579,7 +2584,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<input type="date" id="edit-year" name="anschaffungsjahr">
</div>
<div class="form-group">
<label for="edit-cost">Anschaffungskosten:</label>
<label for="edit-cost">Anschaffungskosten (€):</label>
<input id="edit-cost" name="anschaffungskosten">
</div>
<div class="form-group">
@@ -3339,7 +3344,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
// Function to load items from server
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
// Dynamic page size: scan viewport/screen size to only load the exact amount needed.
const isMobileDevice = window.matchMedia('(max-width: 768px)').matches;
const MAIN_ADMIN_ITEMS_PAGE_SIZE = isMobileDevice ? 20 : 120;
let mainAdminItemsNextOffset = 0;
let mainAdminItemsHasMore = false;
let mainAdminItemsLoadingMore = false;
@@ -3397,10 +3404,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
styles.flexDirection === 'column';
const distanceToEnd = isMobileLayout
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
? Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - (window.scrollY + window.innerHeight)
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = isMobileLayout
? Math.max(220, itemsContainer.clientHeight * 0.9)
? window.innerHeight * 1.5
: Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
@@ -3523,7 +3530,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
const imagesHtml = item.Images ? item.Images.map((image, index) => {
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((image, index) => {
// Check if the image already has a full URL path or just the filename
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
image :
@@ -3772,6 +3779,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (!mainAdminItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
window.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
mainAdminItemsScrollPrefetchBound = true;
}
@@ -3793,7 +3801,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
updateMainAdminItemsLoadingIndicator();
});
}, {
root: itemsContainer,
root: isMobileLayout ? null : itemsContainer,
threshold: 0.15,
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
});
@@ -4355,7 +4363,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return `
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
<div style="border:1px solid var(--ui-border);border-radius:8px;padding:10px;background:var(--ui-surface);display:grid;gap:6px;">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
@@ -4433,7 +4441,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="detail-group">
<div class="detail-label">Anschaffungskosten:</div>
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
</div>
<div class="detail-group full-width">
@@ -4527,6 +4535,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
@@ -5494,7 +5503,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
.filter-group {
background: #ffffff;
background: var(--ui-surface);
border: 1px solid #cfd8e3;
border-radius: 12px;
padding: 12px;
@@ -5542,7 +5551,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
.clear-filter {
background: #fff;
background: var(--ui-surface);
color: #495868;
}
@@ -5551,7 +5560,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
border: 1px solid #cfd8e3;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(20, 35, 60, 0.12);
background: #ffffff;
background: var(--ui-surface);
padding: 10px;
}
@@ -5661,7 +5670,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
display: block !important;
margin-right: 0 !important;
margin-bottom: 2px !important;
color: #6c757d !important;
color: var(--ui-text-muted) !important;
font-size: 0.8rem !important;
}
-40
View File
@@ -70,27 +70,6 @@
</div>
</div>
{% if student_cards_module_enabled %}
<div class="form-group">
<label style="display:flex; align-items:center; gap:8px; color:#1f2937;">
<input type="checkbox" id="is-student" name="is_student" style="width:auto;">
Schülerkonto mit Ausweis
</label>
<div id="student-fields" style="display:none; margin-top:10px;">
<label for="student-card-id">Ausweis-ID</label>
<div class="input-container">
<span class="input-icon">🪪</span>
<input type="text" id="student-card-id" name="student_card_id" placeholder="z.B. S-2026-001">
</div>
<label for="max-borrow-days" style="margin-top:10px;">Standard-Ausleihdauer (Tage)</label>
<div class="input-container">
<span class="input-icon">📅</span>
<input type="number" id="max-borrow-days" name="max_borrow_days" min="1" max="{{ student_max_borrow_days }}" value="{{ student_default_borrow_days }}">
</div>
</div>
</div>
{% endif %}
<div class="form-group">
<label for="permission-preset">Berechtigungs-Preset</label>
<select id="permission-preset" name="permission_preset" class="form-select">
@@ -561,25 +540,6 @@ document.addEventListener('DOMContentLoaded', function () {
passwordInput.addEventListener('blur', updatePasswordRules);
updatePasswordRules();
}
{% if student_cards_module_enabled %}
const studentCheckbox = document.getElementById('is-student');
const studentFields = document.getElementById('student-fields');
const studentCardInput = document.getElementById('student-card-id');
if (!studentCheckbox || !studentFields || !studentCardInput) {
return;
}
function toggleStudentFields() {
const isChecked = studentCheckbox.checked;
studentFields.style.display = isChecked ? 'block' : 'none';
studentCardInput.required = isChecked;
}
studentCheckbox.addEventListener('change', toggleStudentFields);
toggleStudentFields();
{% endif %}
});
</script>
{% endblock %}
+1 -1
View File
@@ -863,7 +863,7 @@
<input type="date" id="anschaffungsjahr" name="anschaffungsjahr">
</div>
<div class="form-group">
<label for="anschaffungskosten">Anschaffungskosten</label>
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten">
</div>
<div class="form-group">
+12
View File
@@ -196,3 +196,15 @@ networks:
# - Medium (5-10 tenants): 4GB RAM, 2-4 CPU cores
# - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores
# - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering
# Management Container for multi-tenant scripts
tenant-manager:
image: bash
container_name: tenant-manager
restart: "no"
profiles:
- tools
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace
entrypoint: ["bash", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
+1
View File
@@ -0,0 +1 @@
394722
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
# 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
show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo ""
echo "Commands:"
echo " add <tenant_id> 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 ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh restart-all"
exit 1
}
if [ -z "$1" ]; then
show_help
fi
COMMAND=$1
TENANT_ID=$2
case "$COMMAND" in
add)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
echo "Adding new tenant '$TENANT_ID'..."
# Add Nginx configuration
if [ -f "docker/nginx/multitenant.conf" ]; then
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..."
fi
# 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
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.users.insert_one({'username': 'admin', 'password': 'hashed_password_here', 'role': 'admin'})
print(f'Tenant {sys.argv[1]} database initialized.')
" "$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
+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 "$@"