Compare commits

...

42 Commits

Author SHA1 Message Date
Aiirondev_dev c0c61a41a3 few needet repairs tro the return of the upload item funktion to not return json but a redirect 2026-06-25 15:02:00 +02:00
Aiirondev_dev 9e74d0c16d Adjustmentws to prevent over engeneering fo extra routes 2026-06-25 09:44:03 +02:00
Aiirondev_dev c8818edf0b better documentation for error finding and slight adjustments for library_loan_admin _ensure_audit_indexes_once 2026-06-25 09:37:44 +02:00
Aiirondev_dev b270bc9367 another relevant change to password security for the salt that is generated for every user Indivisdualy, to fix the temporary approach from before 2026-06-24 19:41:22 +02:00
Aiirondev_dev e1c284fc40 additional changes to the generatio of the tenant 2026-06-24 17:24:34 +02:00
Aiirondev_dev debd4dceb1 additional changes for the encoding 2026-06-24 17:02:56 +02:00
Aiirondev_dev db15df57c3 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-06-24 17:02:30 +02:00
Aiirondev_dev bcfe2095d5 slight fix of the encoding of the Processing for firsdt user generation 2026-06-24 17:01:20 +02:00
Aiirondev_dev 69ac6eca63 Some more fixes of the manage-tenants.sh file 2026-06-24 15:55:21 +02:00
Aiirondev_dev 717e3ce15e Merge remote-tracking branch 'refs/remotes/origin/main' 2026-06-24 12:51:33 +02:00
Aiirondev_dev 007ae04bf3 SIGHT CHANGES FOR THE CORRECT STARTUP OF THE DATABASE 2026-06-24 12:51:15 +02:00
Aiirondev_dev c0947c5cf1 fix for the adding process of manage-tenant 2026-06-24 00:38:47 +02:00
Aiirondev_dev c1d2935ad6 nother changes for the removal of the tenant Databases 2026-06-24 00:30:25 +02:00
Aiirondev_dev 9fde0b4b2a slight fixes of the removal process 2026-06-24 00:18:21 +02:00
Aiirondev_dev da7b075cc4 addition of thew help for tenant managment 2026-06-23 23:57:35 +02:00
Aiirondev_dev 347f258b8f adjustment of the encryption for user password 2026-06-23 22:41:53 +02:00
Aiirondev_dev 2421f867ed Smal changes for Module access 2026-06-23 14:40:27 +02:00
Aiirondev_dev 921915e92f new slight adjust of some namings in thze dropdown 2026-06-23 14:26:39 +02:00
Aiirondev_dev a4b0866293 permision changes for testing 2026-06-23 13:03:51 +02:00
Aiirondev_dev e10d86da4b manage te damaged Item ffield 2026-06-23 12:26:09 +02:00
Aiirondev_dev f160f23e9c sdmal changes 2026-06-23 12:01:23 +02:00
Aiirondev_dev ca272542de Merge remote-tracking branch 'refs/remotes/origin/main' 2026-06-22 00:21:26 +02:00
Aiirondev_dev 7df9b65389 Changes from sha512 to scrypt to fit with DS-GVO 2026-06-22 00:21:15 +02:00
Aiirondev_dev e4af76c9c1 smal change of the upload layout and text, changes of the Student Barcode download to alow further scoolyears 2026-06-20 13:54:42 +02:00
Aiirondev_dev f5944bf090 implementation of the Item Media Type 2026-06-20 12:01:21 +02:00
Aiirondev_dev 4e68da2368 Second Changes to include the Scool name into the Bibliotheksausweis 2026-06-18 13:09:57 +02:00
Aiirondev_dev 0d6aca4e8c added the school_name to the Schülerausweis 2026-06-18 12:56:10 +02:00
Aiirondev_dev 2bb0cbe599 Implmentation of a autonmatic integration of the price of a book 2026-06-17 16:27:02 +02:00
Aiirondev_dev 339487b4d4 Implementierung des Preses bei Fallback 4 2026-06-17 16:14:38 +02:00
Aiirondev_dev 3f90e2d4f1 Fixes for the secondary information getting with isbn.de 2026-06-17 16:07:37 +02:00
Aiirondev_dev b238b52fbf impoved request handeling for ISBN 2026-06-17 15:55:21 +02:00
Aiirondev_dev 545dd7783c New implementation of a nother scanning methode for ISBN 2026-06-17 11:10:53 +02:00
Aiirondev_dev f4ce1cf3b4 license Changes 2026-06-12 23:45:49 +02:00
Aiirondev_dev 7bd12bddaa Titel adding to the client version oif the termin 2026-06-12 22:14:19 +02:00
Aiirondev_dev b8e23b94d2 final changes for the changes to add the Titel to the Terminplanes 2026-06-12 22:00:20 +02:00
Aiirondev_dev 06b32b4a3d temporary debug mode 2026-06-12 21:45:06 +02:00
Aiirondev_dev d24bd6ba56 smal fixes for a error in the variable passing in the backend after changes in blueprint and terminconfig 2026-06-12 20:59:25 +02:00
Aiirondev_dev d1f48cf184 Name fixes 2026-06-12 20:50:31 +02:00
Aiirondev_dev 32fccd17ec some minor fixes 2026-06-12 20:43:51 +02:00
Aiirondev_dev 01447220a0 Some smal fixes for the Terminplaner 2026-06-12 20:34:01 +02:00
Aiirondev_dev b3aeb289ef required thingis removed 2026-06-12 19:59:05 +02:00
Aiirondev_dev a6b90a6a9d fix of a spelling mistake 2026-06-12 19:50:01 +02:00
18 changed files with 603 additions and 411 deletions
+1 -2
View File
@@ -8,5 +8,4 @@ INVENTAR_APP_MEM_SWAP_LIMIT=768m
INVENTAR_WORKERS=4
INVENTAR_THREADS=2
INVENTAR_WORKER_TIMEOUT=30
INVENTAR_WORKER_CONNECTIONS=100
INVENTAR_WORKER_CONNECTIONS=100
+2
View File
@@ -1,5 +1,7 @@
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
build: null
ports:
+8
View File
@@ -556,6 +556,14 @@ sudo chmod 644 certs/inventarsystem.crt
## Fehlerbehebung
### Logs Auslesen
```bash
docker logs inventarsystem-app-1
docker exec inventarsystem-app-1 cat /data/logs/application.log | tail -n 100
docker compose exec mongodb mongosh
```
### Webserver startet nicht
```bash
+167 -25
View File
@@ -25,6 +25,7 @@ from werkzeug.routing import BuildError
from jinja2 import TemplateNotFound
import os
import sys
from bs4 import BeautifulSoup
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -730,6 +731,7 @@ AUDIT_INDEXES_READY = False
def _ensure_audit_indexes_once():
"""Ensure audit indexes exist once per process."""
global AUDIT_INDEXES_READY
if AUDIT_INDEXES_READY:
return
@@ -1735,7 +1737,6 @@ def is_valid_isbn13(isbn13):
check_digit = (10 - (checksum % 10)) % 10
return check_digit == int(isbn13[12])
def normalize_and_validate_isbn(isbn_raw):
"""Normalize ISBN and return a valid canonical ISBN-13/10 or empty string."""
isbn = normalize_isbn(isbn_raw)
@@ -1995,7 +1996,7 @@ def _upload_student_cards_excel():
synonyms = {
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
'first_name': ['vorname', 'first_name', 'firstname'],
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'],
'last_name': ['nachname', 'last_name', 'lastname'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
@@ -3820,7 +3821,7 @@ def library_admin():
show_library_features=True,
upload_mode='library',
page_title='Bücher hochladen',
back_target='home_admin'
back_target='library'
)
@@ -4123,10 +4124,13 @@ def student_card_barcode_download():
c.setLineWidth(2)
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
school_name = cfg.get_school_info()
school_name = school_name["name"]
# "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 9)
c.setFillColor(white)
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS")
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
# Student name - large and bold
c.setFillColor(text_dark)
@@ -4150,7 +4154,11 @@ def student_card_barcode_download():
c.setFillColor(text_dark)
c.setFont("Helvetica-Bold", 9)
c.drawString(x_pos + 3*mm, y_pos - 28*mm, card['Klasse'])
c.drawString(x_pos + 3*mm, y_pos - 31*mm, "-")
c.drawString(x_pos + 3*mm, y_pos - 34*mm, "-")
c.drawString(x_pos + 3*mm, y_pos - 37*mm, "-")
c.drawString(x_pos + 3*mm, y_pos - 40*mm, "-")
# Right barcode section with border highlight
barcode_x_start = x_pos + info_width + 1*mm
c.setFillColor(accent_color)
@@ -4290,10 +4298,12 @@ def student_card_single_barcode_download(card_id):
c.setLineWidth(2.5)
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
school_name = cfg.get_school_info()
school_name = school_name["name"]
# "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 11)
c.setFillColor(white)
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS")
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
# Student name - large and prominent
c.setFillColor(text_dark)
@@ -5064,7 +5074,7 @@ def upload_item():
Enhanced for mobile browser compatibility.
Returns:
flask.Response: Redirect to admin homepage or JSON response
flask.Response: Redirect to admin homepage
"""
# Check if the user is authenticated
if 'username' not in session:
@@ -5077,7 +5087,12 @@ def upload_item():
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
success_redirect_endpoint = 'home_admin' if can_access_admin_home else 'home'
if can_access_admin_home:
success_redirect_endpoint = 'home_admin'
elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'):
success_redirect_endpoint = 'home_library'
else:
success_redirect_endpoint = 'home'
# Detect if request is from mobile device
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
@@ -5106,6 +5121,7 @@ def upload_item():
upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item'))
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
try:
item_count = int(item_count_raw) if item_count_raw else 1
@@ -5185,6 +5201,8 @@ def upload_item():
item_isbn = isbn_raw
if upload_mode == 'library':
item_type = 'book'
if item_type_input != "":
item_type = item_type_input
if upload_mode == 'library':
if not cfg.MODULES.is_enabled('library'):
@@ -6031,22 +6049,9 @@ def upload_item():
except Exception:
app.logger.warning('Audit write failed for library_item_created')
if is_mobile:
return jsonify({
'success': True,
'message': success_msg,
'itemId': str(item_id),
'stats': {
'processed': processed_count,
'errors': error_count,
'skipped': skipped_count,
'duplicates': len(duplicate_images) if duplicate_images else 0,
'totalImages': len(image_filenames)
}
})
else:
flash(success_msg, 'success')
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
flash(success_msg, 'success')
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
else:
error_msg = 'Fehler beim Hinzufügen des Elements'
if is_mobile:
@@ -9539,6 +9544,134 @@ def _fetch_from_open_library(clean_isbn):
print(f"OpenLibrary Search error: {e}")
return None
def _fetch_from_isbn_de(clean_isbn):
"""
Source 4: isbn.de (Maßgeschneidertes Scraping basierend auf realem HTML)
Extrahiert alle Buchdaten inklusive Preise aus den Meta-Tags und der Sidebar.
"""
try:
url = f"https://www.isbn.de/buch/{clean_isbn}"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
response = requests.get(url, headers=headers, timeout=5)
if response.status_code != 200:
return None
soup = BeautifulSoup(response.text, 'html.parser')
# 1. Titel aus Meta-Tags extrahieren (extrem zuverlässig)
title_meta = soup.find('meta', property='og:title')
title = title_meta.get('content', '').strip() if title_meta else None
if not title:
h1_elem = soup.find('h1')
title = h1_elem.text.strip() if h1_elem else "Unknown Title"
# Falls Fehler- oder Suchseite
if "nicht gefunden" in title.lower() or "suche" in title.lower():
return None
# --- Hilfsfunktion zum Parsen der .infotab-Sidebar-Struktur ---
def get_sidebar_value(keyword):
infotab = soup.find(class_='infotab')
if infotab:
for d in infotab.find_all('div'):
if d.text.strip().lower() == keyword.lower():
parent = d.parent
# Label vom Gesamttext abziehen, um nur den Wert zu erhalten
return parent.text.replace(d.text, '', 1).strip()
return None
# 2. Verlag holen
publisher = get_sidebar_value('verlag')
if not publisher:
publisher = "Unknown Publisher"
# 3. Erscheinungsdatum (Aus Meta-Tag oder Sidebar)
date_meta = soup.find('meta', property='og:book:release_date')
if date_meta and date_meta.get('content'):
published_date = date_meta.get('content', '').strip()
else:
published_date = get_sidebar_value('erschienen am')
if not published_date:
published_date = "Unknown Date"
# 4. Autor (Da Schulbuch-Workbooks oft keinen Einzelautor haben, kluger Fallback)
author_meta = soup.find('meta', property='og:book:author')
author = author_meta.get('content', '').strip() if author_meta else ""
if not author:
author = get_sidebar_value('autor') or get_sidebar_value('herausgeber')
if not author:
author = f"{publisher} Redaktion" if publisher != "Unknown Publisher" else "Unknown Author"
# 5. Seitenanzahl
page_count = get_sidebar_value('seiten') or get_sidebar_value('umfang')
if page_count:
match = re.search(r'\d+', page_count)
page_count = match.group(0) if match else "Unknown"
else:
page_count = "Unknown"
# 6. Beschreibung aus ID 'bookdesc' holen und Whitespace-Fluss säubern
description = "Keine Beschreibung verfügbar"
desc_div = soup.find(id='bookdesc')
if desc_div:
# Holt den Text inklusive aller Listenpunkte (li) und formatiert ihn sauber
description = " ".join(desc_div.text.split())
# 7. Cover-Bild (Nutzt hochauflösenden Link aus den Metas)
thumbnail = ""
img_meta = soup.find('meta', property='og:image')
if img_meta:
thumbnail = img_meta.get('content', '').strip()
else:
img_tag = soup.find('img', id='ISBNcover')
if img_tag:
thumbnail = img_tag.get('data-big') or img_tag.get('src')
if thumbnail and thumbnail.startswith('/'):
thumbnail = "https://www.isbn.de" + thumbnail
# 8. PREIS EXTRAHIEREN (Neu integriert)
price = None
# Option A: Aus dem standardisierten Meta-Tag extrahieren (Ergibt z.B. 12.25)
price_meta = soup.find('meta', property='product:price:amount')
if price_meta and price_meta.get('content'):
try:
price = float(price_meta.get('content').strip())
except ValueError:
pass
# Option B: Fallback über die Infotab-Sidebar (Falls Meta fehlt, filtert "12,25 €*" zu Float)
if price is None:
price_str = get_sidebar_value('preis')
if price_str:
# Findet Zahlen wie "12,25" oder "12.25"
match = re.search(r'\d+([.,]\d+)?', price_str)
if match:
try:
price = float(match.group(0).replace(',', '.'))
except ValueError:
pass
return {
"title": title,
"authors": author,
"publisher": publisher,
"publishedDate": published_date,
"description": description,
"pageCount": page_count,
"price": price, # Gibt nun z.B. 12.25 als float zurück
"thumbnail": thumbnail,
"source": "isbn-de"
}
except Exception as e:
print(f"isbn.de Scraping error: {e}")
return None
@app.route('/fetch_book_info/<isbn>')
def fetch_book_info(isbn):
"""
@@ -9562,6 +9695,7 @@ def fetch_book_info(isbn):
providers = [
_fetch_from_google_books,
_fetch_from_lobid_germany,
_fetch_from_isbn_de,
_fetch_from_open_library
]
@@ -10130,10 +10264,18 @@ def notifications_unread_status():
@app.route('/admin/damaged_items')
def admin_damaged_items():
"""Dedicated admin management window for damaged items."""
if 'username' not in session or not us.check_admin(session['username']):
if 'username' not in session:
flash('Administratorrechte erforderlich.', 'error')
return redirect(url_for('login'))
'''
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
if cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_admin'))
'''
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
+1 -2
View File
@@ -284,7 +284,6 @@ def _match_inventory(path):
if path == '/' or path.startswith('/home'): return True
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
def _match_terminplan(path):
if not path:
return False
@@ -292,7 +291,7 @@ def _match_terminplan(path):
def _match_library(path):
if not path: return False
return path.startswith(('/library', '/library_', '/student_cards'))
return path.startswith(('/library', '/library_', '/student_cards', '/admin_damaged_items', '/admin_borrowings', '/admin/library'))
def _match_student_cards(path):
if not path: return False
+2 -1
View File
@@ -38,7 +38,7 @@ def _active_record_query(extra_query=None):
return base_query
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False):
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str=""):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
@@ -53,6 +53,7 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'user': user,
'mail': mail,
'note': note,
'title': title,
'calendar_enabled': bool(calendar_enabled),
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(),
+90 -105
View File
@@ -20,6 +20,8 @@ import string
from bson.objectid import ObjectId
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
import hmac
import os
logger = logging.getLogger('app')
logger.setLevel(logging.DEBUG)
@@ -430,103 +432,84 @@ def check_password_strength(password):
return True
def hashing(password):
def hashing(password, salt=None):
"""
Hash a password using SHA-512.
Hasht ein Passwort mit scrypt.
- Wenn kein Salt übergeben wird, wird ein sicherer, zufälliger Salt generiert (für neue Passwörter).
- Format für neue Hashes: v1$<salt_hex>$<hash_hex>
"""
password_bytes = password.encode('utf-8') # Explizit UTF-8 für Plattformunabhängigkeit
Args:
password (str): Password to hash
Returns:
str: Hexadecimal digest of the hashed password
if salt is None:
# Neuer Benutzer / Passwortänderung -> Dynamischer Salt
random_salt = os.urandom(16)
hashed = hashlib.scrypt(password_bytes, salt=random_salt, n=16384, r=8, p=1)
return f"v1${random_salt.hex()}${hashed.hex()}"
else:
# Bestehender Benutzer (wird zur Verifizierung aufgerufen)
hashed = hashlib.scrypt(password_bytes, salt=salt, n=16384, r=8, p=1)
return hashed.hex()
def verify_password(provided_password, stored_password_string):
"""
return hashlib.sha512(password.encode()).hexdigest()
Verifiziert ein Passwort gegen einen gespeicherten Hash-String.
Unterstützt das alte Format (statischer Salt) und das neue Format (v1$...).
"""
if not stored_password_string:
return False
# Überprüfung für das neue, sichere Format
if stored_password_string.startswith("v1$"):
try:
_, salt_hex, hash_hex = stored_password_string.split("$")
salt_bytes = bytes.fromhex(salt_hex)
# Berechne den Hash des eingegebenen Passworts mit dem extrahierten Salt
calculated_hash = hashing(provided_password, salt=salt_bytes)
# Timing-Attack-sicherer Vergleich
return hmac.compare_digest(calculated_hash, hash_hex)
except (ValueError, TypeError):
logger.error("Ungültiges Hash-Format in der Datenbank entdeckt.")
return False
else:
# Abwärtskompatibilität: Altes Format mit statischem Salt b'some_salt'
old_static_salt = b'some_salt'
calculated_hash = hashing(provided_password, salt=old_static_salt)
return hmac.compare_digest(calculated_hash, stored_password_string)
def check_nm_pwd(username, password):
"""
Verify username and password combination.
Args:
username (str): Username to check
password (str): Password to verify
Returns:
dict: User document if credentials are valid, None otherwise
Überprüft die Kombination aus Benutzername und Passwort (optimiert).
"""
db_name, tenant_id = _resolve_request_tenant_db()
ctx = None
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
except Exception:
ctx = None
if not db_name:
if _has_tenant_configs():
logger.warning(
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
)
logger.warning("Default DB fallback verweigert, da Tenant-Konfigurationen existieren.")
return None
db_name = cfg.MONGODB_DB
logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username,
tenant_id,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
getattr(cfg, 'MONGODB_URI', None),
)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
hashed_password = hashing(password)
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
available_dbs = []
try:
available_dbs = client.list_database_names()
logger.debug("MongoDB connected. Available databases=%s", available_dbs)
except Exception as exc:
logger.exception("Unable to list MongoDB databases: %s", exc)
db = client[db_name]
try:
existing_collections = db.list_collection_names()
except Exception as exc:
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
existing_collections = []
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
users = db['users']
query = {'$or': [{'Username': username}, {'username': username}]}
logger.debug("Running user lookup on %r: %s", db_name, query)
user_record = users.find_one(query)
if user_record is None:
logger.warning("No user document found in db=%r for username=%r", db_name, username)
if db_name not in available_dbs:
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
if 'users' not in existing_collections:
logger.warning("Tenant database %r has no users collection", db_name)
logger.warning("Kein Benutzer für %r in DB %r gefunden.", username, db_name)
return None
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
stored_password = user_record.get('Password') or user_record.get('password')
if stored_password is None:
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
if not verify_password(password, stored_password):
logger.warning("Falsches Passwort für Benutzer %r in DB %r.", username, db_name)
return None
if stored_password != hashed_password:
logger.warning(
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
username,
db_name,
hashed_password,
stored_password,
)
return None
# Automatische Migration alter Hashes auf das neue Format
if not stored_password.startswith("v1$"):
users.update_one({'_id': user_record['_id']}, {'$set': {'Password': hashing(password)}})
return user_record
finally:
@@ -556,44 +539,46 @@ def add_user(
bool: True if user was added successfully, False if password was too weak
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
if not check_password_strength(password):
return False
permission_defaults = build_default_permission_payload(permission_preset)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
permission_defaults['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value)
try:
db = _get_tenant_db(client)
users = db['users']
if not check_password_strength(password):
return False
permission_defaults = build_default_permission_payload(permission_preset)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
permission_defaults['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value)
user_doc = {
'Username': username,
'Password': hashing(password),
'Admin': False,
'active_ausleihung': None,
'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
'PagePermissions': permission_defaults['pages'],
}
user_doc = {
'Username': username,
'Password': hashing(password),
'Admin': False,
'active_ausleihung': None,
'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
'PagePermissions': permission_defaults['pages'],
}
normalized_card = normalize_student_card_id(student_card_id)
if bool(is_student):
if normalized_card:
user_doc['StudentCardId'] = normalized_card
if max_borrow_days is not None:
try:
user_doc['MaxBorrowDays'] = int(max_borrow_days)
except (TypeError, ValueError):
pass
normalized_card = normalize_student_card_id(student_card_id)
if bool(is_student):
if normalized_card:
user_doc['StudentCardId'] = normalized_card
if max_borrow_days is not None:
try:
user_doc['MaxBorrowDays'] = int(max_borrow_days)
except (TypeError, ValueError):
pass
users.insert_one(user_doc)
client.close()
return True
users.insert_one(user_doc)
return True
finally:
client.close()
def student_card_exists(student_card_id):
+23 -70
View File
@@ -146,9 +146,10 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
return None
try:
slot_minutes = int(item.get('slot_lenght') or 0)
slot_minutes = int(item.get('slot_length') or item.get('slot_lenght') or 0)
except Exception:
slot_minutes = 0
if slot_minutes <= 0:
slot_minutes = 45
@@ -197,23 +198,24 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="") -> dict:
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="") -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
Input:
- date_start: start of the time frae area
- date_end: end of the time frame area
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
- slots: amount of slots that are available
- slot_lenght: the lenght of a slot in minutes
Output:
- link: The link for the user to send to the clients
"""
try:
slots_int = int(slots)
except (ValueError, TypeError):
slots_int = 0
try:
slot_length_int = int(slot_length)
except (ValueError, TypeError):
slot_length_int = 45
normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail)
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title)
normalized_mail = _normalize_mail_list(mail or [])
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title)
id_str = str(id)
tenant_id = _current_tenant_id()
@@ -224,9 +226,11 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
link = host + "/terminplaner/client/" + id_str
if tenant_id:
link += f"?tenant={tenant_id}"
subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
calendar_link = None
if calendar_enabled:
try:
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
@@ -251,19 +255,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
def book_slot(id, date_start_time, name):
"""
Updates slot for the booking per a id
Input:
- id: the id is the id you get from the
- date_start_time: the date of the booking that was selected with date and time
- name: name that the client gave himself
Output:
- bool: if worked or not
"""
try:
# Retrieve the current appointment
item = termin.get_item(id)
if not item:
return False
@@ -281,10 +273,7 @@ def book_slot(id, date_start_time, name):
if existing[0] == date_start_time and existing[1] == name:
return False
# Append the new booking as a tuple (start_time, name)
slots.append((date_start_time, name))
# Update the appointment in the database
success = termin.update(id, slots)
return bool(success)
except Exception as e:
@@ -293,19 +282,12 @@ def book_slot(id, date_start_time, name):
def remove_slot(id, date_start_time, name):
"""
Remove a booked slot for an appointment.
Returns True if the removal succeeded, False otherwise.
"""
try:
# Prefer DB-level remove if available
if hasattr(termin, 'remove_slot'):
removed = termin.remove_slot(id, date_start_time, name)
if removed:
return True
# Fallback: fetch, filter, and replace the slot list
item = termin.get_item(id)
if not item:
return False
@@ -314,7 +296,6 @@ def remove_slot(id, date_start_time, name):
new_slots = []
for s in slots:
try:
# s may be list or tuple like [start_time, name]
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
continue
except Exception:
@@ -329,9 +310,6 @@ def remove_slot(id, date_start_time, name):
def remove_appointment(id):
"""
Remove an entire appointment by id.
"""
try:
return bool(termin.remove(id))
except Exception as e:
@@ -339,17 +317,6 @@ def remove_appointment(id):
return False
def get_available(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
try:
termin_range = termin.get_item(id)
if not termin_range:
@@ -358,22 +325,19 @@ def get_available(id):
date_start = termin_range.get('date_start')
date_end = termin_range.get('date_end')
time_span = termin_range.get('time_span', [])
slot_lenght = termin_range.get('slot_lenght')
total_slots = termin_range.get('slots', 0)
# Ensure numeric fields are cast to int when stored as strings
try:
total_slots = int(termin_range.get('slots', 0) or 0)
except Exception:
total_slots = 0
try:
slot_lenght = int(termin_range.get('slot_lenght') or 0)
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
except Exception:
slot_lenght = termin_range.get('slot_lenght')
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
booked = termin_range.get('slots_booked', []) or []
# Normalize booked entries to dicts for easier consumption
normalized = []
for s in booked:
if isinstance(s, (list, tuple)) and len(s) >= 2:
@@ -393,7 +357,8 @@ def get_available(id):
'date_start': date_start,
'date_end': date_end,
'time_span': time_span,
'slot_lenght': slot_lenght,
'slot_length': slot_length,
'slot_lenght': slot_length,
'slots_total': total_slots,
'slots_booked': normalized,
'slots_left': slots_left,
@@ -403,22 +368,10 @@ def get_available(id):
return {}
def get_available_user(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
return get_available(id)
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
"""Return upcoming appointment plans for overview display."""
user_name = str(user or '').strip()
if not user_name:
return []
+7 -5
View File
@@ -169,13 +169,14 @@ def configure():
end = request.form.get('end_date')
time = request.form.get('time_frame')
slots_amount = request.form.get('slots_amounts')
slot_lenght = request.form.get('slot_lenght')
slot_length = request.form.get('slot_length') # Korrigiert: slot_length
mail = request.form.get('mail', '')
note = request.form.get('note', '')
add_to_calendar = request.form.get('add_to_calendar') == 'on'
titel = request.form.get('titel', '').strip()
title = request.form.get('title', '').strip() # Korrigiert: title statt titel
if not start or not end or not time or not slots_amount or not slot_lenght or not titel:
# Abfrage ebenfalls auf title und slot_length angepasst
if not start or not end or not time or not slots_amount or not slot_length or not title:
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
return render_template(
'termin_configure.html',
@@ -184,7 +185,8 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED,
)
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar, title=titel)
# Variablen im Funktionsaufruf aktualisiert
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title)
flash('Der Terminplan wurde angelegt.', 'success')
return render_template(
'termin_configure.html',
@@ -193,7 +195,7 @@ def configure():
calendar_link=result.get('calendar_link'),
add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED,
title=titel,
title=title,
)
elif request.method == "GET":
return render_template(
+1
View File
@@ -15,3 +15,4 @@ openpyxl
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
beautifulsoup4
+1 -4
View File
@@ -1321,10 +1321,7 @@
{% 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>
{% if current_permissions.pages.get('library_loans_admin', True) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen / Defekte Items</a></li>
{% endif %}
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
+6 -10
View File
@@ -28,11 +28,10 @@
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
type="button" role="tab">Rechtsgrundlage</button>
</li>
</ul>
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
<div class="tab-pane" id="pane-notice" role="tabpanel">
<div class="license-content">
<h2>NOTICE Urheberrechtliche Hinweise</h2>
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
@@ -59,14 +58,14 @@
<h4>Frontend / Laufzeit-Assets</h4>
<ul>
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
<li>quagga2 (MIT) © 2014 Christoph Oberhofer, © 2019 Eric Blade and contributors</li>
</ul>
<hr>
</div>
</div><!-- /#pane-notice -->
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-privacy" role="tabpanel">
<div class="tab-pane" id="pane-privacy" role="tabpanel">
<div class="license-content">
<h2>Datenschutzerklärung (Privacy Policy)</h2>
@@ -108,7 +107,7 @@
</div><!-- /#pane-privacy -->
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-security" role="tabpanel">
<div class="tab-pane" id="pane-security" role="tabpanel">
<div class="license-content">
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
@@ -129,7 +128,7 @@
</div><!-- /#pane-security -->
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-data" role="tabpanel">
<div class="tab-pane" id="pane-data" role="tabpanel">
<div class="license-content">
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
@@ -157,7 +156,7 @@
</div><!-- /#pane-data -->
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-legal" role="tabpanel">
<div class="tab-pane" id="pane-legal" role="tabpanel">
<div class="license-content">
<h2>Rechtsgrundlage der Nutzung</h2>
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
@@ -170,9 +169,6 @@
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
</ol>
</div>
</div><!-- /#pane-legal -->
</div><!-- /.tab-content -->
</div>
</div>
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5">
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
<h1 class="h3 fw-bold mb-3">Termin buchen</h1>
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
<div class="p-3 rounded-3 bg-light mb-3">
+161 -48
View File
@@ -14,15 +14,17 @@
<div class="card-body p-4 p-md-5 bg-white">
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
<div class="row g-3">
<labal for="title" class="form-label fw-semibold">Titel des Terminplans</label>
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }} required>">
<div class="col-12">
<label for="title" class="form-label fw-semibold">Titel des Terminplans</label>
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
</div>
<div class="col-12 col-md-6">
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg">
</div>
<div class="col-12 col-md-6">
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg">
</div>
</div>
@@ -30,39 +32,49 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
<div>
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div>
<div class="form-text mb-0">Die Start-, End- und Pausenzeiten werden auf alle erzeugten Tage angewandt.</div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label>
<div class="col-12 col-sm-6 col-lg-3">
<label for="default_day_start" class="form-label fw-semibold">Standard-Start</label>
<input type="time" id="default_day_start" class="form-control" value="08:00">
</div>
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label>
<input type="time" id="default_day_end" class="form-control" value="12:00">
<div class="col-12 col-sm-6 col-lg-3">
<label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
<input type="time" id="default_day_end" class="form-control" value="16:00">
</div>
<div class="col-12 col-sm-6 col-lg-3">
<label for="default_pause_start" class="form-label fw-semibold text-danger">Pause von</label>
<input type="time" id="default_pause_start" class="form-control border-danger-subtle" value="12:00">
</div>
<div class="col-12 col-sm-6 col-lg-3">
<label for="default_pause_end" class="form-label fw-semibold text-danger">Pause bis</label>
<input type="time" id="default_pause_end" class="form-control border-danger-subtle" value="12:45">
</div>
</div>
<div id="time_frame_days" class="vstack gap-2"></div>
<div class="mt-3">
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea>
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster (Übertragung ans Backend)</label>
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" readonly></textarea>
<div class="form-text">Pausen teilen den Tag automatisch in zwei buchbare Blöcke, sodass die Pause sicher blockiert ist.</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
</div>
<div class="col-12 col-md-6">
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
<input type="hidden" id="slots_amounts" name="slots_amounts" value="0">
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
</div>
</div>
@@ -76,7 +88,6 @@
<label for="note" class="form-label fw-semibold">Notiz</label>
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
</div>
{% endif %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
@@ -96,7 +107,7 @@
<div class="alert alert-success mt-4 shadow-sm rounded-4">
<div class="fw-bold mb-1">Buchungslink erstellt</div>
<div class="mb-2">
{% if email_service_enabled %}
{% if mail_service_enabled %}
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
{% else %}
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
@@ -121,30 +132,31 @@
const buildButton = document.getElementById('build_time_frame');
const daysContainer = document.getElementById('time_frame_days');
const timeFrameTextarea = document.getElementById('time_frame');
const defaultStartInput = document.getElementById('default_day_start');
const defaultEndInput = document.getElementById('default_day_end');
const defaultPauseStartInput = document.getElementById('default_pause_start');
const defaultPauseEndInput = document.getElementById('default_pause_end');
const slotLengthInput = document.getElementById('slot_length');
const slotsAmountsInput = document.getElementById('slots_amounts');
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) {
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
return;
}
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
function parseDate(value) {
if (!value) {
return null;
}
if (!value) return null;
const parts = value.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return null;
}
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
return new Date(parts[0], parts[1] - 1, parts[2]);
}
@@ -165,16 +177,92 @@
return copy;
}
function timeToMinutes(timeStr) {
if (!timeStr) return 0;
const parts = timeStr.split(':').map(Number);
return parts[0] * 60 + parts[1];
}
function updateSlotsDisplay(totalSlots) {
if (slotsAmountsInput) slotsAmountsInput.value = totalSlots;
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
}
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
function calculateSlots() {
if (!slotLengthInput || !slotsAmountsInput) return;
const slotLength = parseInt(slotLengthInput.value, 10);
if (isNaN(slotLength) || slotLength <= 0) {
updateSlotsDisplay(0);
return;
}
let totalSlots = 0;
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
rows.forEach(row => {
const startTime = row.querySelector('[data-time-start]')?.value;
const endTime = row.querySelector('[data-time-end]')?.value;
const pauseStart = row.querySelector('[data-time-pause-start]')?.value;
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value;
if (!startTime || !endTime) return;
const startMins = timeToMinutes(startTime);
const endMins = timeToMinutes(endTime);
const pauseStartMins = timeToMinutes(pauseStart);
const pauseEndMins = timeToMinutes(pauseEnd);
if (endMins <= startMins) return; // Ungültige Zeit
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
// Segment 1 (Vor der Pause)
const segment1 = pauseStartMins - startMins;
totalSlots += Math.floor(segment1 / slotLength);
// Segment 2 (Nach der Pause)
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
const segment2 = endMins - effectivePauseEnd;
if (segment2 > 0) {
totalSlots += Math.floor(segment2 / slotLength);
}
} else {
// Ohne Pause oder ungültige Pause -> Komplett durchrechnen
totalSlots += Math.floor((endMins - startMins) / slotLength);
}
});
updateSlotsDisplay(totalSlots);
}
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
function syncTextarea() {
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
const lines = rows.map(function (row) {
const lines = [];
rows.forEach(function (row) {
const dayValue = row.getAttribute('data-day-value') || '';
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
return `${dayValue} ${startTime}-${endTime}`.trim();
}).filter(Boolean);
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
if (!dayValue || !startTime || !endTime) return;
// Hat der Nutzer eine Pause eingetragen, die innerhalb des Tagesfensters liegt?
if (pauseStart && pauseEnd && pauseStart < pauseEnd && pauseStart > startTime && pauseStart < endTime) {
// Zeile am Pausenbeginn aufsplitten -> 2 Blöcke = automatische Pause
lines.push(`${dayValue} ${startTime}-${pauseStart}`);
lines.push(`${dayValue} ${pauseEnd}-${endTime}`);
} else {
lines.push(`${dayValue} ${startTime}-${endTime}`);
}
});
timeFrameTextarea.value = lines.join('\n');
calculateSlots(); // Nach jedem Update neu berechnen
}
function renderRows() {
@@ -197,40 +285,57 @@
endDateInput.setCustomValidity('');
// Bisherige Werte sichern
const existingValues = new Map();
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
const dayValue = row.getAttribute('data-day-value');
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
if (dayValue) {
existingValues.set(dayValue, { startTime, endTime });
existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
}
});
const defaultStart = defaultStartInput.value || '08:00';
const defaultEnd = defaultEndInput.value || '12:00';
const defaultEnd = defaultEndInput.value || '16:00';
const defaultPauseStart = defaultPauseStartInput.value || '';
const defaultPauseEnd = defaultPauseEndInput.value || '';
const rows = [];
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
const dayValue = formatDateForValue(current);
const preserved = existingValues.get(dayValue) || {};
const rowStart = preserved.startTime || defaultStart;
const rowEnd = preserved.endTime || defaultEnd;
const rowStart = preserved.startTime !== undefined ? preserved.startTime : defaultStart;
const rowEnd = preserved.endTime !== undefined ? preserved.endTime : defaultEnd;
const rowPauseStart = preserved.pauseStart !== undefined ? preserved.pauseStart : defaultPauseStart;
const rowPauseEnd = preserved.pauseEnd !== undefined ? preserved.pauseEnd : defaultPauseEnd;
rows.push(`
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
<div class="row g-2 align-items-end">
<div class="col-12 col-lg-5">
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
<div class="text-muted small">${dayValue}</div>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Von</label>
<input type="time" class="form-control" value="${rowStart}" data-time-start>
<div class="col-6 col-lg-2">
<label class="form-label mb-1 text-muted small">Von</label>
<input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Bis</label>
<input type="time" class="form-control" value="${rowEnd}" data-time-end>
<div class="col-6 col-lg-2">
<label class="form-label mb-1 text-muted small">Bis</label>
<input type="time" class="form-control form-control-sm" value="${rowEnd}" data-time-end>
</div>
<div class="col-6 col-lg-2">
<label class="form-label mb-1 text-danger small">Pause ab</label>
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseStart}" data-time-pause-start>
</div>
<div class="col-6 col-lg-2">
<label class="form-label mb-1 text-danger small">Pause bis</label>
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseEnd}" data-time-pause-end>
</div>
</div>
</div>
@@ -246,17 +351,25 @@
syncTextarea();
}
// Event Listeners Registration
buildButton.addEventListener('click', renderRows);
startDateInput.addEventListener('input', renderRows);
startDateInput.addEventListener('change', renderRows);
endDateInput.addEventListener('input', renderRows);
endDateInput.addEventListener('change', renderRows);
defaultStartInput.addEventListener('change', syncTextarea);
defaultEndInput.addEventListener('change', syncTextarea);
defaultStartInput.addEventListener('change', renderRows);
defaultEndInput.addEventListener('change', renderRows);
defaultPauseStartInput.addEventListener('change', renderRows);
defaultPauseEndInput.addEventListener('change', renderRows);
// Slot Berechnung auf Ändeurng der Länge antriggern
if(slotLengthInput) {
slotLengthInput.addEventListener('input', calculateSlots);
slotLengthInput.addEventListener('change', calculateSlots);
}
if (startDateInput.value && endDateInput.value) {
renderRows();
}
})();
</script>
{% endblock %}
{% endblock %}
+22 -2
View File
@@ -788,7 +788,19 @@
{% if show_library_features %}
<!-- Library Mode: Single Customizable Filter -->
<div class="filter-inputs">
<h3>Kategorie/Typ (customisierbar):</h3>
<h3>Medientyp</h3>
<div class="form-group">
<select name="item_type_input" id="item_type_input">
<option value="" disabled selected>Medientyp auswählen...</option>
<option value="Buch">Buch</option>
<option value="Schulbuch">Schulbuch</option>
<option value="CD">CD</option>
<option value="DVD">DVD</option>
<option value="Sonstiges">Sonstiges</option>
</select>
<small style="display:block; color:#666;">Wählen Sie einen Medientyp aus zur Klassifizierung.</small>
</div>
<h3>Kategorie/Typ:</h3>
<div class="form-group">
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
@@ -942,7 +954,7 @@
<div class="isbn-input-group">
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen abrufen</button>
</div>
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
@@ -1563,6 +1575,7 @@
// Get form fields
const nameField = document.getElementById('name');
const descriptionField = document.getElementById('beschreibung');
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
if (!nameField || !descriptionField) {
alert('Fehler: Formularfelder nicht gefunden.');
@@ -1600,6 +1613,13 @@
nameField.value = bookTitle;
descriptionField.value = description;
// Preis in das Formularfeld eintragen
if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) {
// Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um
let formattedPrice = currentBookData.price.toString().replace('.', ',');
priceField.value = formattedPrice;
}
// Download and import book cover image if available
if (currentBookData.thumbnail) {
downloadBookCover(currentBookData.thumbnail);
+1
View File
@@ -55,6 +55,7 @@ services:
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
environment:
MONGO_INITDB_DATABASE: inventar_default
+108 -136
View File
@@ -23,70 +23,19 @@ ensure_runtime_config_json() {
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
FORCE_REMOVE=false
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
FORCE_REMOVE=true
TENANT_ID="${3:-}"
fi
if [ -z "$TENANT_ID" ]; then
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
{
"ver": "2.6.5",
"tenants": {}
}
EOF
echo "Created default config.json"
fi
}
if [ "$FORCE_REMOVE" != true ]; then
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 "Removal canceled."
exit 0
fi
fi
echo "Removing tenant '$TENANT_ID'..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
port_to_remove=""
if [ -n "$APP_CONTAINER" ]; then
port_to_remove="$({ docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
import sys
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
from tenant import delete_tenant, get_tenant_config
tenant_id = sys.argv[1]
tenant_cfg = get_tenant_config(tenant_id)
port = tenant_cfg.get('port')
if not delete_tenant(tenant_id):
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
sys.exit(1)
if port is not None:
print(port)
PY
} 2>/dev/null)"
echo "Tenant '$TENANT_ID' database and config removed."
else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
:
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
fi
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
fi
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
if [ -n "$port_to_remove" ]; then
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
else
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
fi
get_tenant_aliases() {
local tenant_id="$1"
local normalized alias
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
printf '%s\n' "$tenant_id"
@@ -212,7 +161,7 @@ existing['modules'] = {
'inventory': {'enabled': True},
'library': {'enabled': True},
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
'terminplan': {'enabled': True},
'terminplan': {'enabled': True},
'mail': {'enabled': True},
}
existing['trial'] = trial_config
@@ -245,8 +194,8 @@ initialize_tenant_database() {
return 0
fi
docker exec "$APP_CONTAINER" python3 -c '
import sys, re, datetime, hashlib
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
import sys, os, re, datetime, hashlib
sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web")
from Web.modules.database import settings
@@ -258,7 +207,11 @@ sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
pw_bytes = "admin123".encode("utf-8")
random_salt = os.urandom(16)
hashed = hashlib.scrypt(pw_bytes, salt=random_salt, n=16384, r=8, p=1)
hashed_pw_string = f"v1${random_salt.hex()}${hashed.hex()}"
action_permissions = {
"can_borrow": True,
@@ -294,7 +247,7 @@ page_permissions = {
if db.users.count_documents({"Username": "admin"}) == 0:
db.users.insert_one({
"Username": "admin",
"Password": hashed_pw,
"Password": hashed_pw_string,
"Admin": True,
"active_ausleihung": None,
"name": "Admin",
@@ -319,7 +272,7 @@ if mode == "trial":
)
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
' "$tenant_id" "$mode"
PY
}
update_runtime_ports() {
@@ -435,7 +388,6 @@ restart_app_container() {
ensure_runtime_config_json
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
if [ -n "${HOST_WORKDIR:-}" ]; then
workdir="$HOST_WORKDIR"
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
@@ -446,7 +398,6 @@ restart_app_container() {
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
fi
else
# Normal case: called directly from host
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
@@ -456,7 +407,6 @@ restart_app_container() {
fi
fi
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
fi
@@ -566,24 +516,83 @@ remove_runtime_port() {
fi
}
show_help() {
local HEADER='\033[95m'
local BLUE='\033[94m'
local GREEN='\033[92m'
local YELLOW='\033[93m'
local RED='\033[91m'
local BOLD='\033[1m'
local RESET='\033[0m'
cat << EOF
${HEADER}${BOLD}=== MULTI-TENANT INVENTAR MANAGER ===${RESET}
${YELLOW}Nutzung:${RESET} $0 <befehl> [tenant_id] [optionen]
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
${GREEN}add${RESET} <tenant_id> [port]
Legt einen neuen Tenant an, registriert den Port und initialisiert
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}).
${GREEN}trial${RESET} <tenant_id> [port] [tage]
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage.
Läuft automatisch ab und löscht sich selbst.
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank.
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
${GREEN}restart-tenant${RESET} <tenant_id>
Startet einen spezifischen Tenant neu, indem alle aktiven Sessions
und der Cache in der MongoDB geleert werden (Sitzungs-Reset).
${GREEN}restart-all${RESET}
Führt einen Zero-Downtime Rolling-Restart für alle App-Container durch.
${GREEN}list${RESET}
Listet alle registrierten Tenants aus der 'config.json' sowie alle
aktiven Tenant-Datenbanken aus der MongoDB auf.
${GREEN}module${RESET} <tenant_id> <modulname>=<on|off> ...
Aktiviert oder deaktiviert bestimmte Features/Module für einen Tenant.
Es können mehrere Module gleichzeitig übergeben werden.
${BLUE}${BOLD}GLOBALE OPTIONEN:${RESET}
${GREEN}-h, --help${RESET}
Zeigt diese Hilfe an.
${YELLOW}Beispiele:${RESET}
$0 add schule_muenchen 8081
$0 trial test_user 8082 14
$0 module schule_muenchen barre_code=on leih_historie=off
$0 remove -y test_user
-----------------------------------------------------------------
EOF
}
if [ -z "${1:-}" ]; then
show_help
exit 0
fi
COMMAND="$1"
TENANT_ID="${2:-}"
# === MAIN ROUTING BLOCK ===
case "$COMMAND" in
-h|--help)
show_help
;;
add)
TENANT_ID="${2:-}"
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
PORT_ARG="$3"
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."
@@ -598,68 +607,13 @@ case "$COMMAND" in
fi
echo "Adding new tenant '$TENANT_ID'..."
# Initialize tenant database via Python inside container
echo "Initializing database for $TENANT_ID..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}'
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
if db.users.count_documents({'Username': 'admin'}) == 0:
db.users.insert_one({
'Username': 'admin',
'Password': hashed_pw,
'Admin': True,
'active_ausleihung': None,
'name': 'Admin',
'last_name': 'User',
'IsStudent': False,
'PermissionPreset': 'full_access',
'ActionPermissions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'PagePermissions': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'manage_filters': True,
'manage_locations': True,
},
})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
else
echo "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant."
fi
initialize_tenant_database "$TENANT_ID" "standard"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
;;
trial)
TENANT_ID="${2:-}"
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
@@ -691,9 +645,13 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
remove)
FORCE_REMOVE=false
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
TENANT_ARG="${2:-}"
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
FORCE_REMOVE=true
TENANT_ID="${3:-}"
else
TENANT_ID="$TENANT_ARG"
fi
if [ -z "$TENANT_ID" ]; then
@@ -713,17 +671,32 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
echo "Removing tenant '$TENANT_ID'..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
port_to_remove=""
if [ -n "$APP_CONTAINER" ]; then
# Zuerst die Datenbank via PyMongo hart droppen (Erzwungenes Löschen)
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
import sys
import sys, re
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
from tenant import delete_tenant, get_tenant_config
from Web.modules.database import settings
from pymongo import MongoClient
tenant_id = sys.argv[1]
tenant_cfg = get_tenant_config(tenant_id)
port = tenant_cfg.get('port')
# Datenbanknamen exakt rekonstruieren
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
try:
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(db_name)
print(f"MongoDB database '{db_name}' dropped successfully.", file=sys.stderr)
except Exception as e:
print(f"Warning: Could not drop database '{db_name}': {e}", file=sys.stderr)
if not delete_tenant(tenant_id):
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
sys.exit(1)
@@ -757,20 +730,19 @@ PY
;;
restart-tenant)
TENANT_ID="${2:-}"
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'); sys.path.insert(0, '/app/Web'); from Web.modules.database 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
db.sessions.drop()
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
@@ -833,12 +805,12 @@ PY
;;
module)
TENANT_ID="${2:-}"
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
exit 1
fi
# Pass all remaining arguments to python script
shift 2
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
@@ -914,4 +886,4 @@ PY
;;
esac
exit 0
exit 0
+1
View File
@@ -15,3 +15,4 @@ openpyxl
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
beautifulsoup4