commit fad7d9a75775462ab6ef489d2bad3ef589e99201 Author: AIIrondev Date: Fri Apr 10 14:48:52 2026 +0200 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aeba76d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +dist +logs +certs +build \ No newline at end of file diff --git a/Backup-DB.py b/Backup-DB.py new file mode 100755 index 0000000..3fd7258 --- /dev/null +++ b/Backup-DB.py @@ -0,0 +1,134 @@ +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +""" +Backup-DB.py + +Usage: + python Backup-DB.py \ + --uri mongodb://user:pass@host:port/ \ + --db my_database \ + --out /path/to/backup_folder +""" +import os +import csv +import sys +import argparse +from datetime import datetime +from pymongo import MongoClient + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Backup a MongoDB database to CSV files (one per collection)." + ) + parser.add_argument( + "--uri", "-u", + required=True, + help="MongoDB URI, e.g. mongodb://user:pass@host:port/" + ) + parser.add_argument( + "--db", "-d", + required=True, + help="Name of the database to back up" + ) + parser.add_argument( + "--out", "-o", + default=f"mongo_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}", + help="Output directory (will be created if it doesn't exist)" + ) + return parser.parse_args() + + +def flatten_dict(d, parent_key="", sep="."): + """ + Flatten nested dicts into a single level, concatenating keys with sep. + Lists are left as-is (converted to string later). + """ + items = {} + for k, v in d.items(): + new_key = f"{parent_key}{sep}{k}" if parent_key else k + if isinstance(v, dict): + items.update(flatten_dict(v, new_key, sep=sep)) + else: + items[new_key] = v + return items + + +def export_collection(db, coll_name, out_dir): + coll = db[coll_name] + cursor = coll.find() + docs = list(cursor) + if not docs: + print(f"[{coll_name}] No documents found, skipping.") + return + + # Flatten and collect all keys + all_keys = set() + flat_docs = [] + for doc in docs: + # Convert ObjectId and other non-serializable types + doc = {k: str(v) for k, v in doc.items()} + flat = flatten_dict(doc) + flat_docs.append(flat) + all_keys.update(flat.keys()) + + out_file = os.path.join(out_dir, f"{coll_name}.csv") + with open(out_file, "w", newline="", encoding="utf-8") as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=sorted(all_keys)) + writer.writeheader() + for flat in flat_docs: + writer.writerow(flat) + + print(f"[{coll_name}] Exported {len(flat_docs)} docs → {out_file}") + + +def main(): + args = parse_args() + + # Create output directory + try: + os.makedirs(args.out, exist_ok=True) + print(f"Backing up database '{args.db}' → '{args.out}'") + except PermissionError: + print(f"Error: Permission denied when creating directory {args.out}") + print("Try running with sudo or to a different output directory") + return 1 + + # Verify write permissions + test_file = os.path.join(args.out, ".write_test") + try: + with open(test_file, 'w') as f: + f.write("test") + os.remove(test_file) + except (IOError, PermissionError) as e: + print(f"Error: Cannot write to output directory {args.out}") + print(f"Reason: {str(e)}") + print("Try running with sudo or to a different output directory") + return 1 + + try: + # Connect to MongoDB + client = MongoClient(args.uri) + # Test connection + client.server_info() + db = client[args.db] + + # Export each collection + for coll_name in db.list_collection_names(): + export_collection(db, coll_name, args.out) + + print("Backup complete.") + return 0 + except Exception as e: + print(f"Error: {str(e)}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/Backup-Invoices.py b/Backup-Invoices.py new file mode 100644 index 0000000..b440fa8 --- /dev/null +++ b/Backup-Invoices.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Backup-Invoices.py + +Exports all invoice records stored in ausleihungen.InvoiceData to dedicated +archive files (JSONL + CSV + metadata). + +Usage: + python Backup-Invoices.py \ + --uri mongodb://localhost:27017/ \ + --db Inventarsystem \ + --out /var/backups/invoice-archive/invoices-2026-04-06_12-00-00 +""" + +import argparse +import csv +import json +import os +import sys +from datetime import datetime + +from bson import ObjectId +from pymongo import MongoClient + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Export invoice records from ausleihungen to JSONL and CSV." + ) + parser.add_argument("--uri", "-u", required=True, help="MongoDB URI") + parser.add_argument("--db", "-d", required=True, help="MongoDB database name") + parser.add_argument( + "--out", + "-o", + required=True, + help=( + "Output file base path without extension, e.g. " + "/var/backups/invoice-archive/invoices-2026-04-06_12-00-00" + ), + ) + return parser.parse_args() + + +def to_jsonable(value): + if isinstance(value, ObjectId): + return str(value) + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, dict): + return {k: to_jsonable(v) for k, v in value.items()} + if isinstance(value, list): + return [to_jsonable(v) for v in value] + return value + + +def flatten_dict(data, parent_key="", sep="."): + items = {} + for key, value in data.items(): + new_key = f"{parent_key}{sep}{key}" if parent_key else str(key) + if isinstance(value, dict): + items.update(flatten_dict(value, new_key, sep=sep)) + elif isinstance(value, list): + items[new_key] = json.dumps(value, ensure_ascii=False) + else: + items[new_key] = value + return items + + +def normalize_invoice_record(doc): + invoice_data = doc.get("InvoiceData") or {} + record = { + "borrow_id": str(doc.get("_id", "")), + "borrow_status": doc.get("Status", ""), + "borrow_user": doc.get("User", ""), + "borrow_item": doc.get("Item", ""), + "borrow_start": doc.get("Start"), + "borrow_end": doc.get("End"), + "invoice_number": invoice_data.get("invoice_number", ""), + "invoice_amount": invoice_data.get("amount"), + "invoice_amount_text": invoice_data.get("amount_text", ""), + "invoice_damage_reason": invoice_data.get("damage_reason", ""), + "invoice_created_at": invoice_data.get("created_at"), + "invoice_created_by": invoice_data.get("created_by", ""), + "invoice_borrower": invoice_data.get("borrower", ""), + "invoice_item_id": invoice_data.get("item_id", ""), + "invoice_item_name": invoice_data.get("item_name", ""), + "invoice_item_code": invoice_data.get("item_code", ""), + "invoice_mark_destroyed": invoice_data.get("mark_destroyed", False), + "invoice_status_before": invoice_data.get("status_before_invoice", ""), + "invoice_raw": invoice_data, + } + return to_jsonable(record) + + +def write_jsonl(path, records): + with open(path, "w", encoding="utf-8") as handle: + for row in records: + handle.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def write_csv(path, records): + if not records: + with open(path, "w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["info"]) + writer.writerow(["no invoice records found"]) + return + + flat_records = [flatten_dict(row) for row in records] + fieldnames = sorted({key for row in flat_records for key in row.keys()}) + + with open(path, "w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in flat_records: + writer.writerow(row) + + +def write_metadata(path, db_name, out_base, count): + payload = { + "db": db_name, + "exported_at": datetime.utcnow().isoformat() + "Z", + "record_count": count, + "files": { + "jsonl": out_base + ".jsonl", + "csv": out_base + ".csv", + }, + "schema_note": "Source is ausleihungen.InvoiceData", + } + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + + +def main(): + args = parse_args() + out_base = args.out + out_dir = os.path.dirname(out_base) or "." + + try: + os.makedirs(out_dir, exist_ok=True) + except Exception as exc: + print(f"Error: cannot create output directory {out_dir}: {exc}") + return 1 + + client = None + try: + client = MongoClient(args.uri) + client.server_info() + db = client[args.db] + ausleihungen = db["ausleihungen"] + + cursor = ausleihungen.find({"InvoiceData": {"$exists": True, "$ne": None}}) + records = [normalize_invoice_record(doc) for doc in cursor] + + jsonl_path = out_base + ".jsonl" + csv_path = out_base + ".csv" + meta_path = out_base + ".meta.json" + + write_jsonl(jsonl_path, records) + write_csv(csv_path, records) + write_metadata(meta_path, args.db, out_base, len(records)) + + print(f"Invoice backup complete: {len(records)} records") + print(f"JSONL: {jsonl_path}") + print(f"CSV: {csv_path}") + print(f"META: {meta_path}") + return 0 + except Exception as exc: + print(f"Error: {exc}") + return 1 + finally: + if client: + client.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..dad443b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +Iron.ai.dev@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..86a031d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.12-slim + +ARG NUITKA_BUILD=0 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Minimal build/runtime deps for Pillow and general Python packages. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + libjpeg62-turbo-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app + +# Optional compiled mode: compile Web/app.py with Nuitka as a Python extension module. +# Gunicorn keeps the same service interface (`app:app`), but imports compiled code. +RUN if [ "$NUITKA_BUILD" = "1" ]; then \ + pip install --no-cache-dir nuitka ordered-set zstandard; \ + python -m nuitka --module /app/Web/app.py --output-dir=/app/Web; \ + mv /app/Web/app.py /app/Web/app.py.source; \ + fi + +WORKDIR /app/Web +EXPOSE 8000 + +CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..2c5f992 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,55 @@ + # Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen +**Softwareprojekt:** Inventarsystem +**Urheberrechtshalter (Lizenzgeber):** Maximilian Gründinger +**Gültigkeit:** Stand 2026 + +--- + +### PRÄAMBEL +Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag“) stellt eine rechtsgültige Vereinbarung zwischen Ihnen (im Folgenden „Lizenznehmer“) und dem Urheber **AIIrondev** (im Folgenden „Lizenzgeber“) dar. Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software „Inventarsystem“ (im Folgenden „Produkt“) erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen vollumfänglich einverstanden. + +Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung, Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt. + +--- + +### § 1 GEGENSTAND DER LIZENZ UND EIGENTUMSRECHTE +1. Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen Kopien davon verbleiben ausschließlich beim Lizenzgeber. +2. Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag explizit genannten Bedingungen. + +### § 2 ZULÄSSIGER NUTZUNGSKREIS (PRIVATNUTZUNG) +1. Die unentgeltliche Nutzung des Produkts ist ausschließlich **natürlichen Personen für den rein privaten, häuslichen Gebrauch** gestattet. +2. Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht. +3. Jegliche Nutzung durch **Institutionelle Nutzer** (einschließlich, aber nicht beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen, Behörden oder NGOs) ist ausdrücklich **untersagt** und bedarf einer gesonderten, schriftlichen Lizenzvereinbarung mit dem Lizenzgeber. + +### § 3 FUNKTIONALE EINSCHRÄNKUNGEN UND SUPPORT +1. Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is“). +2. Für die kostenlose Privatnutzung besteht **kein Anspruch** auf: + - Technischen Support oder Beratung. + - Bereitstellung von Sicherheits-Updates oder Patches. + - Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen. +3. Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern, einzuschränken oder kostenpflichtig zu gestalten. + +### § 4 WAHRUNG DER URHEBERBEZEICHNUNG (BRANDING-KLAUSEL) +1. Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung **„Powered by AIIrondev“** in der Benutzeroberfläche (Footer/Menü). +2. Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder Metadaten) zu beeinträchtigen. +3. Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der Nutzungslizenz. + +### § 5 VERBOT DER KOMMERZIELLEN VERWERTUNG & SAAS +1. Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS), insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt. +2. Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des Lizenzgebers zulässig. + +### § 6 MODIFIKATIONEN UND CONTRIBUTIONS +1. Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren. +2. Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben. + +### § 7 HAFTUNGSBESCHRÄNKUNG +1. Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn), ist auf Vorsatz und grobe Fahrlässigkeit beschränkt. +2. Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten Daten. + +### § 8 RECHTSWAHL UND GERICHTSSTAND +1. Es gilt ausschließlich das Recht der **Bundesrepublik Deutschland** unter Ausschluss des UN-Kaufrechts (CISG). +2. Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der Sitz des Lizenzgebers vereinbart. +3. Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt (Salvatorische Klausel). + +--- +**ANFRAGEN FÜR AUSNAHMEGENEHMIGUNGEN (KOMMERZIELLE LIZENZEN):** Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil: [AIIrondev auf GitHub](https://github.com/AIIrondev) diff --git a/Legal/DATA_PROCESSING.md b/Legal/DATA_PROCESSING.md new file mode 100644 index 0000000..7f2e877 --- /dev/null +++ b/Legal/DATA_PROCESSING.md @@ -0,0 +1,17 @@ +# Dokumentation der Datenverarbeitung (VVT) + +## Systemübersicht +Das **Inventarsystem** ist eine Anwendung zur Erfassung und Verwaltung von physischen Gütern. + +## Datenfluss +1. **Eingabe:** Nutzer gibt Daten über das Frontend/API ein. +2. **Speicherung:** Daten werden in einer lokalen Datenbank verschlüsselt abgelegt. +3. **Ausgabe:** Anzeige der Bestände für autorisierte Nutzer. + +## Technische und Organisatorische Maßnahmen (TOM) +- **Zugangskontrolle:** Authentifizierung über Benutzerkonten. +- **Integrität:** Validierung der Eingabedaten zur Vermeidung von Datenbankfehlern. +- **Verfügbarkeit:** Empfohlene Backup-Strategien für die Datenbank. + +## Empfänger der Daten +Die Daten verbleiben innerhalb der kontrollierten Umgebung der Installation. Es findet kein automatisierter Export an Dritte statt. \ No newline at end of file diff --git a/Legal/LEGAL_BASIS.md b/Legal/LEGAL_BASIS.md new file mode 100644 index 0000000..3a7e9d8 --- /dev/null +++ b/Legal/LEGAL_BASIS.md @@ -0,0 +1,9 @@ +# Rechtsgrundlage der Nutzung + +Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen: + +1. **Innerbetriebliche Nutzung:** Die Verarbeitung ist zur Durchführung des Beschäftigungsverhältnisses erforderlich (§ 26 BDSG / Art. 88 DSGVO). +2. **Berechtigtes Interesse:** Das Unternehmen hat ein berechtigtes Interesse daran, zu wissen, wo sich Arbeitsmittel befinden (Art. 6 Abs. 1 lit. f DSGVO). +3. **Einwilligung:** Falls private Daten der Nutzer erfasst werden, muss eine explizite Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO). + +**Hinweis:** Dieses Dokument stellt keine Rechtsberatung dar! \ No newline at end of file diff --git a/Legal/NOTICE.txt b/Legal/NOTICE.txt new file mode 100644 index 0000000..730fd6f --- /dev/null +++ b/Legal/NOTICE.txt @@ -0,0 +1,42 @@ +Inventarsystem +Copyright © 2025-2026 AIIrondev + +Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). +You may not use this project except in compliance with the License. +The full license text can be found in the file Legal/LICENSE. + +This software is provided for private, non-commercial use only. +Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. +For commercial licensing inquiries: https://github.com/AIIrondev + +Third-party notices +This project depends on, uses, or references the following third‑party +software. Their licenses apply as noted. Where applicable, copyright +notices are reproduced below. This NOTICE is for attribution only and +does not modify the terms of the licenses. + +Python packages (runtime): +- Flask (BSD-3-Clause) © Pallets +- Werkzeug (BSD-3-Clause) © Pallets +- Jinja2 (BSD-3-Clause) © Pallets (indirect via Flask) +- Click (BSD-3-Clause) © Pallets (indirect via Flask) +- Gunicorn (MIT) © 2009-2025 Benoit Chesneau and contributors +- PyMongo (Apache-2.0) © MongoDB, Inc. +- APScheduler (MIT) © 2009-2025 Alex Grönholm and contributors +- Pillow (HPND/FreeType-style license) © 1995-2011 Fredrik Lundh and contributors; © 2010-2025 Alex Clark and contributors +- python-dateutil (BSD-3-Clause) © 2017-2025 dateutil team; © 2003-2011 Gustavo Niemeyer +- pytz (MIT) © 2003-2025 Stuart Bishop +- requests (Apache-2.0) © 2011-2025 Kenneth Reitz and contributors +- qrcode (BSD-3-Clause) © 2010-2025 Lincoln Loop and contributors + +Frontend/runtime assets: +- html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas) + +System components (not distributed with this repository, may be used in deployment): +- NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors +- FFmpeg (LGPL/GPL, depending on configuration) © 2000-2025 FFmpeg developers + +Your modifications +If you create Derivative Works and distribute them, please add your own +attribution statements here, alongside the above notices, and indicate +any files you changed as required by the Apache License, Section 4. \ No newline at end of file diff --git a/Legal/PRIVACY.md b/Legal/PRIVACY.md new file mode 100644 index 0000000..ee87a99 --- /dev/null +++ b/Legal/PRIVACY.md @@ -0,0 +1,27 @@ +# Datenschutzerklärung (Privacy Policy) + +## 1. Einleitung +Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Dieses Dokument erläutert, wie das "Inventarsystem" personenbezogene Daten verarbeitet. + +## 2. Verantwortliche Stelle +Verantwortlich für die Datenverarbeitung in diesem Repository ist der Betreiber der jeweiligen Instanz (Self-Hosted). Bei Fragen wenden Sie sich bitte an den Administrator Ihrer Installation. + +## 3. Erhobene Daten +Das System verarbeitet folgende Kategorien von Daten: +- **Benutzerdaten:** Benutzername, (je nach Nutzer/Administrator Name und Nachname). +- **Inventardaten:** Bezeichnungen von Gegenständen, Mengen, Standorte, ggf. Zuordnungen zu Personen. +- **Protokolldaten:** Zeitstempel von Änderungen (Logs), IP-Adressen (serverseitig zur Sicherheit). + +## 4. Zweck der Verarbeitung +Die Daten werden ausschließlich zu folgenden Zwecken verarbeitet: +- Verwaltung und Organisation von Beständen. +- Sicherstellung der Systemsicherheit. +- Nachvollziehbarkeit von Bestandsänderungen. + +## 5. Rechtsgrundlage (DSGVO) +Die Verarbeitung erfolgt auf Basis von: +- **Art. 6 Abs. 1 lit. b DSGVO:** Erfüllung eines Vertrages oder vorvertraglicher Maßnahmen. +- **Art. 6 Abs. 1 lit. f DSGVO:** Berechtigtes Interesse (effiziente Bestandsverwaltung). + +## 6. Datenlöschung +Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind, sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen. \ No newline at end of file diff --git a/Legal/SECURITY.md b/Legal/SECURITY.md new file mode 100644 index 0000000..805a388 --- /dev/null +++ b/Legal/SECURITY.md @@ -0,0 +1,12 @@ +# Sicherheitsrichtlinie (Security Policy) + +## Unterstützte Versionen +Bitte nutzen Sie immer die neueste Version aus dem `main`-Branch, um Sicherheitsupdates zu erhalten. + +## Datenschutzrechtliche Mechanismen +- **Passwort-Hashing:** Passwörter werden niemals im Klartext gespeichert. +- **Session-Management:** Automatisches Logout nach Inaktivität (konfigurierbar). +- **Berechtigungskonzept:** Rollenbasierte Zugriffskontrolle (RBAC), um den Zugriff auf "Need-to-know"-Basis zu beschränken. + +## Meldung von Sicherheitslücken +Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte kein öffentliches Issue, sondern kontaktieren Sie den Maintainer direkt per E-Mail oder über die GitHub Security Advisory Funktion. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..421addb --- /dev/null +++ b/README.md @@ -0,0 +1,656 @@ +# Inventarsystem + +[![wakatime](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243.svg)](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243) + +**Aktuelle Version: 3.2.1** + +Ein modernes webbasiertes Inventarverwaltungssystem zur Verwaltung, Ausleihe, Reservierung und Rückgabe von Gegenständen. +Das System richtet sich insbesondere an Bildungseinrichtungen, Organisationen und Labore. + +--- + +## Inhaltsverzeichnis + +- [Systemübersicht](#systemübersicht) +- [Hauptfunktionen](#hauptfunktionen) +- [Installation](#installation) +- [Docker Betrieb](#docker-betrieb) +- [Erste Einrichtung](#erste-einrichtung) +- [Systembetrieb](#systembetrieb) +- [Benutzerverwaltung](#benutzerverwaltung) +- [Artikelverwaltung](#artikelverwaltung) +- [Buchungssystem](#buchungssystem) +- [Backup & Wiederherstellung](#backup--wiederherstellung) +- [Wartung & Updates](#wartung--updates) +- [Versionsverwaltung](#versionsverwaltung) +- [Konfiguration](#konfiguration) +- [Fehlerbehebung](#fehlerbehebung) +- [Systemanforderungen](#systemanforderungen) +- [Lizenz Rechtliches & Datenschutz](#lizenz-rechtliches--datenschutz) + +--- + +## Systemübersicht + +Das Inventarsystem stellt folgende Wartungsskripte bereit: + +| Skript | Beschreibung | +|---|---| +| `update.sh` | Aktualisiert Docker-Deployment aus GitHub Releases | +| `fix-all.sh` | Intelligentes Reparaturskript | +| `rebuild-venv.sh` | Python-Umgebung neu erstellen (nur Quellcode-Entwicklung) | +| `start.sh` | Dienste starten | +| `stop.sh` | Dienste stoppen | +| `restart.sh` | Dienste neu starten | +| `build-nuitka.sh` | Standalone-Build der App mit Nuitka erstellen | +| `init-admin.sh` | Initialisiert Admin-User wenn DB leer ist | +| `create-user.sh` | Erstellt neue User per Kommandozeile | +| `Backup-DB.py` | Manuelles DB-Backup | +| `restore.sh` | Backup wiederherstellen | +| `manage-version.sh` | Versionssteuerung | + +--- + +## Hauptfunktionen + +### Benutzeranmeldung + +- Sichere Passwortregeln (mindestens 6 Zeichen) +- Rollenbasiertes System + - Administrator + - Standardbenutzer +- Session-basierte Authentifizierung + +### Artikelverwaltung + +- Artikel hinzufügen / löschen +- Ausleihen & Rückgabe +- Metadatenverwaltung +- Anschaffungsdaten (Jahr, Kosten) +- Mehrfach-Bildupload +- UUID-basierte Dateinamen +- Detaillierte Artikelansicht + +### Buchungssystem + +- Konfliktprüfung +- Automatische Aktivierung +- Automatische Beendigung +- Kalenderansicht +- Perioden-Unterstützung (Schulstunden) + +### Barcode-Scanner + +- Integrierter Scanner +- Schnelles Auffinden von Artikeln +- Mobile-optimiert + +### Filtersystem + +- Dreistufige Filter +- Kombinierbare Suche +- Kategorie-Management + +### Administrator-Tools + +- Benutzerverwaltung +- Ausleihprotokolle +- Artikel-Reset +- Standortverwaltung + +### Responsive Design + +- Mobil optimiert +- Touch-freundlich +- Desktop-fähig + +--- + +## Installation + +### Voraussetzungen + +- Python >= 3.7 +- MongoDB +- pip +- Linux-System (empfohlen) + +### Installation (automatisch) + +**Option 1** + +```bash +wget -O - https://raw.githubusercontent.com/aiirondev/Inventarsystem/main/install.sh | sudo bash +``` + +**Option 2** + +```bash +curl -s https://raw.githubusercontent.com/aiirondev/Inventarsystem/main/install.sh | sudo bash +``` + +Legacy-MongoDB uebernehmen und altes Host-System aufraeumen: + +```bash +curl -s https://raw.githubusercontent.com/aiirondev/Inventarsystem/main/install.sh | \ + sudo bash -s -- --migrate-legacy-db --remove-legacy-system +``` + +Optional kann ein alter Systempfad nach erfolgreicher Migration entfernt werden: + +```bash +sudo ./install.sh --migrate-legacy-db --remove-legacy-system --legacy-system-dir /opt/Inventarsystem-alt +``` + +Ab dieser Version wird nach der Installation standardmaessig ein Alt-System-Cleanup ausgefuehrt +(u. a. alte Inventarsystem/Admin-Systemd-Dienste stoppen/deaktivieren, Restprozesse und stale Sockets entfernen). + +Optionales Verhalten beim Install: + +```bash +# Alt-System-Cleanup komplett ueberspringen +sudo ./install.sh --skip-cleanup-old + +# Beim Alt-System-Cleanup auch passende Cron-Eintraege entfernen +sudo ./install.sh --cleanup-old-remove-cron +``` + +--- + +## Docker Betrieb + +Das System läuft produktiv Docker-first. Deployments und Updates erfolgen über Release-Artefakte (Build-only), nicht über Quellcode-Pulls. + +- Laufzeit-Stack: [docker-compose.yml](docker-compose.yml) +- Build-Pipeline: [Dockerfile](Dockerfile) +- Release-Pipeline: [.github/workflows/release-docker.yml](.github/workflows/release-docker.yml) +- Frontend/Reverse Proxy: Nginx (Container) + +### Voraussetzungen + +- Docker Engine +- Docker Compose Plugin + +### Starten (portabel auf jedem Docker-Host) + +```bash +docker compose up -d +``` + +Oder mit Hilfsskript: + +```bash +./start.sh +``` + +Danach ist die Web-App erreichbar unter: + +```text +https://[SERVER-IP] +``` + +Wenn keine Zertifikate vorhanden sind, erstellt `start.sh` automatisch ein selbstsigniertes Zertifikat unter `certs/inventarsystem.crt` und `certs/inventarsystem.key`. + +Wenn Docker/Compose/OpenSSL fehlen, installiert `start.sh` die benoetigten Pakete automatisch. +Dabei wird zuerst `docker.io` versucht. Falls das auf dem System nicht verfuegbar ist, richtet das Skript das Docker-Repository ein und installiert `docker-ce` inklusive Compose-Plugin. + +Standardmaessig richtet `start.sh` zusaetzlich die taeglichen Cron-Jobs fuer Backup (02:30) und Update (03:00) ein. +Das kann bei Bedarf deaktiviert werden mit `./start.sh --no-cron` oder per Umgebungsvariable `INVENTAR_SETUP_CRON=0 ./start.sh`. + +### App mit Nuitka neu bauen + +Fuer einen Standalone-Build der Flask-App steht folgendes Skript bereit: + +```bash +./build-nuitka.sh +``` + +Das Skript: + +- verwendet die Python-Umgebung unter `.venv` +- installiert/aktualisiert Nuitka Build-Abhaengigkeiten +- baut `Web/app.py` als Standalone-Binary +- bindet `Web/templates`, `Web/static` und `uploads` als Laufzeitdaten ein +- schreibt das Ergebnis nach `dist/app.dist/` + +### Stoppen + +```bash +docker compose down +``` + +Oder mit Hilfsskript: + +```bash +./stop.sh +``` + +### Logs ansehen + +```bash +docker compose logs -f app +docker compose logs -f mongodb +docker compose logs -f nginx +``` + +### Persistente Daten + +Die Volumes sind in [docker-compose.yml](docker-compose.yml) definiert: + +- MongoDB Daten +- Uploads / Thumbnails / Previews / QRCodes +- Backups und Logs + +Hinweis: Für Container-Deployments werden MongoDB- und Speicherpfade via Umgebungsvariablen in [Web/settings.py](Web/settings.py) überschrieben. + +### Updates (nur aus Releases) + +```bash +sudo ./update.sh +``` + +`update.sh` lädt ausschließlich das Release-Asset `inventarsystem-docker-bundle.tar.gz` aus dem neuesten GitHub Release, lädt das passende Release-Image lokal aus dem Release-Archiv und startet den Stack ohne lokalen Rebuild neu. +Wenn lokal ein passendes Image-Artefakt unter `dist/` liegt (`inventarsystem-image-.tar.gz` oder `inventarsystem-image-*.tar.gz`), wird dieses zuerst verwendet. +Zusätzlich wird ein Health-Check ausgeführt; bei Fehlern endet das Update mit Exit-Code ungleich 0 und protokolliert Container-Logs in `logs/update.log`. + +### Release-Erstellung (Build-only) + +Beim Push eines Tags `v*` erstellt GitHub Actions automatisch: + +- Container-Image in GHCR: `ghcr.io/aiirondev/inventarsystem:` +- Release-Asset `inventarsystem-docker-bundle.tar.gz` (nur Docker-Deployment-Dateien) +- Release-Asset `inventarsystem-image-.tar.gz` (offline Docker image export) + +Damit enthalten Releases nur Build-Artefakte für Docker, nicht den produktiven Updatepfad über Roh-Quellcode. + +--- + +## Erste Einrichtung + +Nach der Installation: + +```bash +cd /opt/Inventarsystem +sudo ./start.sh +``` + +Öffnen Sie dann im Browser: + +``` +https://[SERVER-IP] +``` + +### Admin-User erstellen + +Wenn die Datenbank leer ist (beim ersten Start), erstellen Sie einen Admin-User: + +**Option 1: Automatisiert beim Start** +```bash +./init-admin.sh +``` + +Mit benutzerdefinierten Daten: +```bash +./init-admin.sh myadmin mypassword123 Max Mustermann +``` + +**Option 2: Interaktiv** +```bash +cd Web && python3 generate_user.py +``` + +**Option 3: Per Kommandozeile** +```bash +./create-user.sh admin password123456 Admin User --admin +``` + +**Option 4: Direkt in MongoDB (Notlösung)** +```bash +docker exec inventarsystem-mongodb mongosh --eval " +db.users.insertOne({ + Username: 'admin', + Password: '\$(python3 -c \"import hashlib; print(hashlib.sha512(b\\\"deinPassword123\\\").hexdigest())\")' , + Admin: true, + active_ausleihung: null, + name: 'Admin', + last_name: 'User', + favorites: [] +}) +" Inventarsystem +``` + +--- + +## Systembetrieb + +### Starten + +```bash +sudo ./start.sh +``` + +### Stoppen + +```bash +sudo ./stop.sh +``` + +### Neustarten + +```bash +sudo ./restart.sh +``` + +### Status prüfen + +```bash +docker compose ps +docker compose logs -f app +``` + +--- + +## Benutzerverwaltung + +### Erstes Admin-Konto erstellen + +```bash +cd /pfad/zum/Inventarsystem +source .venv/bin/activate +python Web/generate_user.py +``` + +### Benutzer über GUI hinzufügen + +1. Als Admin anmelden +2. Benutzer verwalten +3. Neuen Benutzer hinzufügen +4. Daten eingeben +5. Speichern + +--- + +## Artikelverwaltung + +### Artikel hinzufügen + +1. Admin anmelden +2. Artikel hochladen +3. Formular ausfüllen +4. Bilder hochladen +5. Speichern + +### Unterstützte Formate + +- JPG / JPEG +- PNG +- GIF +- MP4 +- MOV + +### Artikel bearbeiten + +1. Bearbeitungssymbol klicken +2. Änderungen speichern + +### Artikel löschen + +1. Mülleimer klicken +2. Bestätigen + +--- + +## Buchungssystem + +### Artikel ausleihen + +1. Artikel öffnen +2. Ausleihen klicken + +Das System protokolliert automatisch. + +### Artikel zurückgeben + +1. Meine ausgeliehenen Artikel öffnen +2. Zurückgeben klicken + +### Buchung planen + +1. Terminplan öffnen +2. Zeitraum wählen +3. Speichern + +--- + +## Backup & Wiederherstellung + +### Backup erstellen + +Universell (empfohlen, erkennt Host/Docker automatisch): + +```bash +sudo ./backup.sh --mode auto +``` + +Docker-only (gleiche Logik, optional): + +```bash +sudo ./backup.sh --mode docker +``` + +Alternativ (Host-Backupskript): + +```bash +sudo ./backup.sh +``` + +Backup-Pfad: + +``` +/var/backups/Inventarsystem-YYYY-MM-DD.tar.gz +``` + +Rechnungs-Archiv (separat, rechtssicher): + +``` +/var/backups/invoice-archive/invoices-YYYY-MM-DD_HH-MM-SS.jsonl +/var/backups/invoice-archive/invoices-YYYY-MM-DD_HH-MM-SS.csv +/var/backups/invoice-archive/invoices-YYYY-MM-DD_HH-MM-SS.meta.json +``` + +Standard-Aufbewahrung fuer Rechnungsarchive: + +- 3650 Tage (10 Jahre) + +Optional konfigurierbar: + +```bash +sudo ./backup.sh --invoice-keep-days 3650 +sudo ./backup.sh --invoice-archive-dir /var/backups/invoice-archive +``` + +### Backup wiederherstellen + +```bash +sudo ./restore.sh --list +sudo ./restore.sh --date=latest +sudo ./restart.sh +``` + +--- + +## Wartung & Updates + +### System aktualisieren + +```bash +sudo ./update.sh +``` + +Hinweis: Updatepfad ist release-only. Es wird kein `git pull` verwendet. + +### Virtuelle Umgebung neu erstellen + +```bash +sudo ./rebuild-venv.sh +sudo ./restart.sh +``` + +### Komplettreparatur + +```bash +sudo ./fix-all.sh +``` + +--- + +## Versionsverwaltung + +Mit `manage-version.sh` können Sie gezielt Versionen steuern, Downgrades durchführen und Versionen pinnen. + +### Beispiele + +```bash +# Dauerhaft auf eine Version (Tag, Commit oder Branch) pinnen +./manage-version.sh pin v2.5.17 --restart + +# Einmalig auf eine Version wechseln +./manage-version.sh use --restart + +# Aktuellen Status anzeigen +./manage-version.sh status + +# Pin entfernen und zur Hauptversion zurückkehren +./manage-version.sh clear --restart +``` + +### Wichtige Hinweise + +- Pin wird in `.version-lock` gespeichert +- Unterstützt: Tags, Branches, Commits +- Erstellt automatische Backups vor jedem Wechsel +- Bewahrt Datenverzeichnisse über den Versionswechsel hinweg + +--- + +## Konfiguration + +### config.json bearbeiten + +```bash +sudo nano config.json +``` + +Beispiel: + +```json +{ + "dbg": false, + "key": "IhrGeheimSchlüssel", + "ver": "2.6.2", + "host": "0.0.0.0", + "port": 443 +} +``` + +### SSL aktualisieren + +```bash +sudo chmod 600 certs/inventarsystem.key +sudo chmod 644 certs/inventarsystem.crt +``` + +--- + +## Fehlerbehebung + +### Webserver startet nicht + +```bash +docker compose ps +docker compose logs -f nginx +docker compose logs -f app +``` + +### MongoDB-Probleme + +```bash +docker compose restart mongodb +docker compose logs -f mongodb +``` + +### PyMongo/BSON-Konflikt + +```bash +sudo ./fix-all.sh +``` + +oder + +```bash +sudo ./rebuild-venv.sh +``` + +### Bild-Upload Probleme + +```bash +sudo ./fix-all.sh +``` + +Verzeichnisse prüfen: + +```bash +sudo ls -la Web/uploads +sudo ls -la Web/QRCodes +sudo ls -la Web/thumbnails +``` + +### Empfohlener Troubleshooting-Workflow + +1. Status prüfen +2. Logs prüfen +3. `fix-all.sh` ausführen +4. Neustarten + +Automatische Überwachung einrichten: + +```bash +sudo ./fix-all.sh --setup-cron +``` + +--- + +## Systemanforderungen + +- Moderner Webbrowser (Chrome, Firefox, Safari, Edge) +- Internetzugang +- Kamera für QR-Scan +- Desktop empfohlen für Admins + +--- + +## Lizenz Rechtliches & Datenschutz + +Dieses Projekt ist auf Transparenz und Datensparsamkeit ausgelegt. Um einen rechtskonformen Betrieb (insbesondere gemäß DSGVO) zu gewährleisten, wurden folgende Dokumente erstellt: + +* **[Lizenz](./LICENSE)** + +* **[Datenschutzerklärung](./Legal/PRIVACY.md):** Erläutert, welche personenbezogenen Daten (z. B. Inventarzuordnungen, Logins) verarbeitet werden. +* **[Datenverarbeitung & Dokumentation](./Legal/DATA_PROCESSING.md):** Details zu den technischen Abläufen und Speichermechanismen innerhalb des Systems. +* **[Rechtsgrundlage](./Legal/LEGAL_BASIS.md):** Informationen für Administratoren zur rechtmäßigen Nutzung im geschäftlichen oder privaten Umfeld. +* **[Sicherheit & Mechanismen](./Legal/SECURITY.md):** Übersicht der implementierten Schutzmaßnahmen (Hashing, Zugriffskontrolle). + +> **Wichtiger Hinweis:** Die bereitgestellten Dokumente dienen als Vorlage. Als Betreiber einer Instanz dieses Inventarsystems sind Sie selbst dafür verantwortlich, diese an Ihre spezifische Hosting-Umgebung und Ihre internen Prozesse anzupassen. + +--- + +## Mitwirkende + +**Maximilian Gründinger** — Projektgründer + +Für technische Unterstützung oder Fragen bitte ein Issue im GitHub-Repository eröffnen. + +--- + +Das Inventarsystem ist eine robuste, wartungsfreundliche Komplettlösung für Inventarverwaltung mit Fokus auf Bildungseinrichtungen. +Durch automatisierte Wartung, integrierte Backups und intelligente Diagnose lässt sich das System zuverlässig betreiben und skalieren. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5d5c2a1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported Versions + +The latest version will allways be supported the rest are old version that are not activly supported. + +| Version | Supported | +| ------- | ------------------ | +| 3.2.x | :white_check_mark: | +| 3.1.x | :x: | +| 3.0.x | :white_check_mark: | +| 2.6.x | :x: | +| 2.4.x | :x: | +| 1.8.x | :x: | +| 1.7.x | :x: | +| 1.5.x | :x: | +| 1.4.x | :x: | +| 1.3.x | :x: | +| 1.1.x | :x: | + + +## Reporting a Vulnerability + +To report a vulnerability contact me via. my E-Mail Iron.ai.dev@gmail.com or in insevere cases over an Issue. diff --git a/Web/__pycache__/app.cpython-312.pyc b/Web/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..1b31f8d Binary files /dev/null and b/Web/__pycache__/app.cpython-312.pyc differ diff --git a/Web/__pycache__/ausleihung.cpython-312.pyc b/Web/__pycache__/ausleihung.cpython-312.pyc new file mode 100644 index 0000000..33c61c5 Binary files /dev/null and b/Web/__pycache__/ausleihung.cpython-312.pyc differ diff --git a/Web/__pycache__/items.cpython-312.pyc b/Web/__pycache__/items.cpython-312.pyc new file mode 100644 index 0000000..2b0ea72 Binary files /dev/null and b/Web/__pycache__/items.cpython-312.pyc differ diff --git a/Web/__pycache__/settings.cpython-312.pyc b/Web/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..7683b8e Binary files /dev/null and b/Web/__pycache__/settings.cpython-312.pyc differ diff --git a/Web/__pycache__/user.cpython-312.pyc b/Web/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..04f8e76 Binary files /dev/null and b/Web/__pycache__/user.cpython-312.pyc differ diff --git a/Web/app.py b/Web/app.py new file mode 100755 index 0000000..0b4b12c --- /dev/null +++ b/Web/app.py @@ -0,0 +1,7536 @@ +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +""" +Inventarsystem - Flask Web Application + +This application provides an inventory management system with user authentication, +item tracking, QR code generation, and borrowing/returning functionality. + +The system uses MongoDB for data storage and provides separate interfaces for +regular users and administrators. + +Features: +- User authentication (login/logout) +- Item management (add, delete, view) +- Borrowing and returning items +- QR code generation for items +- Administrative functions +- History logging of item usage +- Booking and reservation of items +""" + +from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file +from werkzeug.utils import secure_filename +import user as us +import items as it +import ausleihung as au +import datetime +from apscheduler.schedulers.background import BackgroundScheduler +from pymongo import MongoClient +from bson.objectid import ObjectId +from urllib.parse import urlparse, urlunparse +import requests +import os +import json +import datetime +import time +import traceback +import re +import io +import html +# QR Code functionality deactivated +# import qrcode +# from qrcode.constants import ERROR_CORRECT_L +import threading +import sys +import shutil +import uuid +from PIL import Image, ImageOps +import mimetypes +import subprocess + +# Set base directory and centralized settings +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +import settings as cfg + + +app = Flask(__name__, static_folder='static') # Correctly set static folder +app.secret_key = cfg.SECRET_KEY +app.debug = cfg.DEBUG +app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER +app.config['THUMBNAIL_FOLDER'] = cfg.THUMBNAIL_FOLDER +app.config['PREVIEW_FOLDER'] = cfg.PREVIEW_FOLDER +app.config['ALLOWED_EXTENSIONS'] = set(cfg.ALLOWED_EXTENSIONS) +# app.config['QR_CODE_FOLDER'] = cfg.QR_CODE_FOLDER # QR Code storage deactivated + +# Thumbnail sizes +THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE +PREVIEW_SIZE = cfg.PREVIEW_SIZE + +__version__ = cfg.APP_VERSION +Host = cfg.HOST +Port = cfg.PORT + +MONGODB_HOST = cfg.MONGODB_HOST +MONGODB_PORT = cfg.MONGODB_PORT +MONGODB_DB = cfg.MONGODB_DB +SCHEDULER_INTERVAL = cfg.SCHEDULER_INTERVAL_MIN +SSL_CERT = cfg.SSL_CERT +SSL_KEY = cfg.SSL_KEY + +LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media'] +INVOICE_CURRENCY = 'EUR' + + +SCHOOL_PERIODS = cfg.SCHOOL_PERIODS + +# Apply the configuration for general use throughout the app +APP_VERSION = __version__ + + +def _parse_money_value(value): + """Parse a user-facing money value into a float when possible.""" + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + + text = str(value).strip() + if not text: + return None + + cleaned = re.sub(r'[^0-9,\.\-]', '', text) + if ',' in cleaned and '.' in cleaned: + cleaned = cleaned.replace('.', '').replace(',', '.') + else: + cleaned = cleaned.replace(',', '.') + + try: + return float(cleaned) + except ValueError: + return None + + +def _format_money_value(value): + """Format a money value in German notation for display.""" + parsed_value = _parse_money_value(value) + if parsed_value is None: + return '-' + formatted = f"{parsed_value:,.2f}".replace(',', 'X').replace('.', ',').replace('X', '.') + return f"{formatted} {INVOICE_CURRENCY}" + + +def _build_invoice_number(borrow_id, created_at): + """Build a stable, human-readable invoice number.""" + short_id = str(borrow_id)[-6:].upper() + return f"INV-{created_at.strftime('%Y%m%d-%H%M%S')}-{short_id}" + + +def _build_invoice_pdf(invoice_data): + """Render a PDF invoice for a damaged borrowed item.""" + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.lib.colors import HexColor, black, white + from reportlab.lib.utils import simpleSplit + from reportlab.pdfgen import canvas + + pdf_buffer = io.BytesIO() + c = canvas.Canvas(pdf_buffer, pagesize=A4) + page_width, page_height = A4 + + margin_x = 20 * mm + margin_top = 20 * mm + usable_width = page_width - (2 * margin_x) + current_y = page_height - margin_top + + dark_color = HexColor('#0F172A') + accent_color = HexColor('#B91C1C') + light_color = HexColor('#F8FAFC') + border_color = HexColor('#CBD5E1') + text_color = HexColor('#1E293B') + muted_color = HexColor('#64748B') + + def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color): + if not text: + return y_pos + c.setFont(font_name, font_size) + c.setFillColor(color) + for line in simpleSplit(str(text), font_name, font_size, width): + c.drawString(x_pos, y_pos, line) + y_pos -= leading + return y_pos + + def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm): + c.setFont('Helvetica-Bold', 10) + c.setFillColor(muted_color) + c.drawString(x_pos, y_pos, label) + c.setFont('Helvetica', 10) + c.setFillColor(text_color) + c.drawString(x_pos + label_width, y_pos, str(value or '-')) + return y_pos - 7 * mm + + c.setFillColor(light_color) + c.rect(0, 0, page_width, page_height, fill=1, stroke=0) + + c.setFillColor(dark_color) + c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0) + c.setFillColor(white) + c.setFont('Helvetica-Bold', 20) + c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG') + c.setFont('Helvetica', 10) + c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt') + + current_y = page_height - 40 * mm + c.setStrokeColor(border_color) + c.setLineWidth(1) + c.line(margin_x, current_y, page_width - margin_x, current_y) + current_y -= 12 * mm + + invoice_number = invoice_data.get('invoice_number', '-') + created_at = invoice_data.get('created_at_display', '-') + borrower = invoice_data.get('borrower', '-') + item_name = invoice_data.get('item_name', '-') + item_code = invoice_data.get('item_code', '-') + item_id = invoice_data.get('item_id', '-') + damage_reason = invoice_data.get('damage_reason', '-') + amount_text = invoice_data.get('amount_text', '-') + + current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y) + current_y = draw_label_value('Datum:', created_at, margin_x, current_y) + current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y) + current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y) + current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y) + current_y = draw_label_value('Code:', item_code, margin_x, current_y) + current_y = current_y - 3 * mm + + c.setFillColor(dark_color) + c.setFont('Helvetica-Bold', 12) + c.drawString(margin_x, current_y, 'Schadensbeschreibung') + current_y -= 6 * mm + current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color) + current_y -= 4 * mm + + c.setFillColor(dark_color) + c.setFont('Helvetica-Bold', 12) + c.drawString(margin_x, current_y, 'Rechnungsbetrag') + current_y -= 8 * mm + + c.setFillColor(accent_color) + c.setStrokeColor(accent_color) + c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0) + c.setFillColor(white) + c.setFont('Helvetica-Bold', 16) + c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text) + current_y -= 20 * mm + + current_y = draw_wrapped_lines( + 'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.', + margin_x, + current_y, + usable_width, + font_size=10, + leading=13, + color=muted_color, + ) + + footer_y = 18 * mm + c.setStrokeColor(border_color) + c.setLineWidth(0.8) + c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm) + c.setFillColor(muted_color) + c.setFont('Helvetica', 9) + c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung') + c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}') + + c.save() + pdf_buffer.seek(0) + return pdf_buffer + + +def _prepare_invoice_pdf_payload(invoice_data, borrow_doc=None, item_doc=None): + """Normalize stored invoice data for robust PDF rendering.""" + borrow_doc = borrow_doc or {} + item_doc = item_doc or {} + + created_at_raw = invoice_data.get('created_at') + created_at_display = '' + if isinstance(created_at_raw, datetime.datetime): + created_at_display = created_at_raw.strftime('%d.%m.%Y %H:%M') + elif created_at_raw: + created_at_display = str(created_at_raw) + + amount_value = _parse_money_value(invoice_data.get('amount')) + if amount_value is None: + amount_value = _parse_money_value(invoice_data.get('amount_text')) + amount_text = invoice_data.get('amount_text') + if not amount_text: + amount_text = _format_money_value(amount_value) + + item_id = invoice_data.get('item_id') + if not item_id and item_doc.get('_id'): + item_id = str(item_doc.get('_id')) + + return { + 'invoice_number': invoice_data.get('invoice_number') or _build_invoice_number(borrow_doc.get('_id', ''), datetime.datetime.now()), + 'created_at': created_at_raw, + 'created_at_display': created_at_display, + 'borrower': invoice_data.get('borrower') or borrow_doc.get('User', '-'), + 'item_name': invoice_data.get('item_name') or item_doc.get('Name', '-'), + 'item_code': invoice_data.get('item_code') or item_doc.get('Code_4', '-'), + 'item_id': str(item_id or '-'), + 'damage_reason': invoice_data.get('damage_reason') or 'Keine Schadensbeschreibung hinterlegt.', + 'amount': amount_value, + 'amount_text': amount_text or '-', + } + +@app.context_processor +def inject_version(): + """Inject global template variables.""" + is_admin = False + if 'username' in session: + try: + is_admin = us.check_admin(session['username']) + except Exception: + is_admin = False + return { + 'APP_VERSION': APP_VERSION, + 'school_periods': SCHOOL_PERIODS, + 'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED, + 'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED, + 'is_admin': is_admin + } + +# Create necessary directories at startup +if not os.path.exists(app.config['UPLOAD_FOLDER']): + os.makedirs(app.config['UPLOAD_FOLDER']) +# QR Code directory creation deactivated +# if not os.path.exists(app.config['QR_CODE_FOLDER']): +# os.makedirs(app.config['QR_CODE_FOLDER']) + +# Create backup directories +BACKUP_FOLDER = cfg.BACKUP_FOLDER +if not os.path.exists(BACKUP_FOLDER): + try: + os.makedirs(BACKUP_FOLDER, exist_ok=True) + except PermissionError: + # Fallback: use a backup directory inside the application directory (writable) + fallback_backup = os.path.join(BASE_DIR, 'backups') + try: + os.makedirs(fallback_backup, exist_ok=True) + BACKUP_FOLDER = fallback_backup + print(f"Warnung: Konnte BACKUP_FOLDER nicht erstellen. Fallback genutzt: {BACKUP_FOLDER}") + except Exception as e: + print(f"Fehler: Backup-Verzeichnis konnte nicht erstellt werden: {e}") + +def create_daily_backup(): + """ + Erstellt täglich ein Backup der Ausleihungsdatenbank + """ + try: + print(f"[{datetime.datetime.now()}] Erstelle Backup der Ausleihungsdatenbank...") + result = au.create_backup_database() + if result: + print(f"[{datetime.datetime.now()}] Backup erfolgreich erstellt") + else: + print(f"[{datetime.datetime.now()}] Fehler beim Erstellen des Backups") + except Exception as e: + print(f"[{datetime.datetime.now()}] Ausnahme beim Erstellen des Backups: {str(e)}") + +def update_appointment_statuses(): + """ + Aktualisiert automatisch die Status aller Terminplaner-Einträge. + Diese Funktion wird jede Minute ausgeführt und überprüft: + - Geplante Termine, die aktiviert werden sollten + - Aktive Termine, die beendet werden sollten + """ + current_time = datetime.datetime.now() + # Prepare logging early so it's available in exception paths + log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs') + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, 'scheduler.log') + + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{current_time}] Starte automatische Statusaktualisierung...\n") + + print(f"[{current_time}] Starte automatische Statusaktualisierung...") + + # Hole alle Termine mit Status 'planned' oder 'active' + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Finde alle Termine, die status updates benötigen + appointments_to_check = list(ausleihungen.find({ + 'Status': {'$in': ['planned', 'active']} + })) + + updated_count = 0 + activated_count = 0 + completed_count = 0 + + for appointment in appointments_to_check: + old_status = appointment.get('Status') + + # Aktuellen Status bestimmen + new_status = au.get_current_status(appointment, log_changes=True, user='scheduler') + + # Wenn sich der Status geändert hat, aktualisiere in der Datenbank + if new_status != old_status: + extra_fields = {} + + # --- Conflict resolver: planned → active transition --- + # Check if the physical item is already borrowed by someone else + if old_status == 'planned' and new_status == 'active': + items_col = db['items'] + item_id_str = appointment.get('Item') + conflict_detected = False + conflict_note = '' + if item_id_str: + try: + item_doc = items_col.find_one( + {'_id': ObjectId(item_id_str)}, + {'Verfuegbar': 1, 'User': 1, 'Name': 1, 'Exemplare': 1} + ) + if item_doc: + total_exemplare = int(item_doc.get('Exemplare', 1)) + # Count how many active (non-planned) borrows currently hold this item + active_borrows = ausleihungen.count_documents({ + 'Item': item_id_str, + 'Status': 'active', + '_id': {'$ne': appointment['_id']} + }) + if active_borrows >= total_exemplare or item_doc.get('Verfuegbar') is False: + conflict_detected = True + borrower = item_doc.get('User', 'unbekannter Benutzer') + item_name = item_doc.get('Name', item_id_str) + conflict_note = ( + f"Gegenstand '{item_name}' war beim Aktivieren von " + f"'{appointment.get('User', '?')}' bereits ausgeliehen " + f"von '{borrower}' (aktive Borrows: {active_borrows}/{total_exemplare})." + ) + extra_fields['ConflictDetected'] = True + extra_fields['ConflictNote'] = conflict_note + extra_fields['ConflictAt'] = current_time + conflict_log = ( + f" [KONFLIKT] Termin {appointment['_id']}: " + f"planned → active, aber {conflict_note}" + ) + print(conflict_log) + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{current_time}] {conflict_log}\n") + else: + # No conflict — clear any previously stored conflict flag + extra_fields['ConflictDetected'] = False + extra_fields['ConflictNote'] = '' + except Exception as conflict_err: + print(f" [WARN] Konfliktprüfung fehlgeschlagen für Termin " + f"{appointment['_id']}: {conflict_err}") + + result = ausleihungen.update_one( + {'_id': appointment['_id']}, + {'$set': { + 'Status': new_status, + 'LastUpdated': current_time, + **extra_fields + }} + ) + + if result.modified_count > 0: + updated_count += 1 + if new_status == 'active': + activated_count += 1 + elif new_status == 'completed': + completed_count += 1 + + log_msg = f" - Termin {appointment['_id']}: {old_status} → {new_status}" + print(log_msg) + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{current_time}] {log_msg}\n") + + client.close() + + if updated_count > 0: + result_msg = f"Statusaktualisierung abgeschlossen: {updated_count} Termine aktualisiert" + detail_msg = f" - {activated_count} aktiviert, {completed_count} abgeschlossen" + print(f"[{current_time}] {result_msg}") + print(f"[{current_time}] {detail_msg}") + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{current_time}] {result_msg}\n") + f.write(f"[{current_time}] {detail_msg}\n") + else: + result_msg = "Statusaktualisierung abgeschlossen: Keine Änderungen erforderlich" + print(f"[{current_time}] {result_msg}") + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{current_time}] {result_msg}\n") + + except Exception as e: + error_msg = f"Fehler bei der automatischen Statusaktualisierung: {str(e)}" + print(f"[{datetime.datetime.now()}] {error_msg}") + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{datetime.datetime.now()}] {error_msg}\n") + except Exception: + pass + import traceback + traceback.print_exc() + +# Schedule jobs +scheduler = BackgroundScheduler() +if cfg.SCHEDULER_ENABLED: + scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS) + scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN) + scheduler.start() + +# Register shutdown handler to stop scheduler when app is terminated +import atexit +atexit.register(lambda: scheduler.shutdown() if cfg.SCHEDULER_ENABLED else None) + +def allowed_file(filename, file_content=None, max_size_mb=cfg.MAX_UPLOAD_MB): + """ + Check if a file has an allowed extension and valid content. + + Args: + filename (str): Name of the file to check + file_content (FileStorage, optional): The actual file object to validate content + max_size_mb (int, optional): Maximum allowed file size in MB + + Returns: + tuple: (bool, str) - True if the file is valid, False otherwise + along with an error message if not valid + """ + # Check file extension + if '.' not in filename: + return False, f"Datei '{filename}' hat keine Dateiendung. Erlaubte Formate: {', '.join(app.config['ALLOWED_EXTENSIONS'])}" + + extension = filename.rsplit('.', 1)[1].lower() + allowed_extensions_lower = {ext.lower() for ext in app.config['ALLOWED_EXTENSIONS']} + if extension not in allowed_extensions_lower: + app.logger.warning(f"File extension not allowed: {extension} for file {filename}. Allowed: {allowed_extensions_lower}") + return False, f"Datei '{filename}' hat ein nicht unterstütztes Format ({extension}). Erlaubte Formate: {', '.join(app.config['ALLOWED_EXTENSIONS'])}" + + # Check file size if content is provided + if file_content is not None: + # Check file size + file_content.seek(0, os.SEEK_END) + file_size = file_content.tell() / (1024 * 1024) # Size in MB + file_content.seek(0) # Reset file pointer to beginning + + if file_size > max_size_mb: + return False, f"Datei '{filename}' ist zu groß ({file_size:.1f} MB). Maximale Größe: {max_size_mb} MB." + + # Verify file content matches extension + try: + if extension in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']: + try: + # Special debug for PNG files + if extension == 'png': + app.logger.info(f"PNG DEBUG: Validating PNG file: {filename}, size: {file_size:.2f}MB") + # Save first few bytes for magic number checking + header_bytes = file_content.read(32) # Reading more bytes for deeper analysis + file_content.seek(0) # Reset pointer + + # Check PNG magic number (first 8 bytes) + png_signature = b'\x89PNG\r\n\x1a\n' + is_valid_signature = header_bytes.startswith(png_signature) + + # Create a hex dump of the header for debugging + hex_dump = ' '.join([f"{b:02x}" for b in header_bytes[:16]]) + app.logger.info(f"PNG DEBUG: File {filename} - Header hex dump: {hex_dump}") + app.logger.info(f"PNG DEBUG: Valid PNG signature: {is_valid_signature}, first bytes: {header_bytes[:8]!r}") + + # More detailed analysis of PNG chunks + if is_valid_signature: + try: + # IHDR chunk should start at byte 8 + if header_bytes[8:12] == b'IHDR': + app.logger.info(f"PNG DEBUG: Found IHDR chunk at correct position") + else: + app.logger.warning(f"PNG DEBUG: IHDR chunk not found at expected position. Found: {header_bytes[8:12]!r}") + except Exception as chunk_err: + app.logger.error(f"PNG DEBUG: Error analyzing PNG chunks: {str(chunk_err)}") + else: + app.logger.error(f"PNG DEBUG: Invalid PNG signature for {filename}. Expected: {png_signature!r}") + + with Image.open(file_content) as img: + # Verify it's a valid image by accessing its format and size + img_format = img.format + img_mode = img.mode + img_size = img.size + + if extension == 'png': + app.logger.info(f"PNG DEBUG: Successfully opened PNG - Format: {img_format}, Mode: {img_mode}, Size: {img_size[0]}x{img_size[1]}") + # Add more PNG-specific checks + app.logger.info(f"PNG DEBUG: Image info - Bands: {len(img.getbands())}, Bands: {img.getbands()}") + # Check if there's transparency + has_alpha = 'A' in img.getbands() or img.mode == 'P' and img.info.get('transparency') is not None + app.logger.info(f"PNG DEBUG: Has transparency: {has_alpha}") + + if not img_format: + if extension == 'png': + app.logger.error(f"PNG DEBUG: Invalid format - got None for {filename}") + return False, f"Datei '{filename}' scheint keine gültige Bilddatei zu sein." + + file_content.seek(0) # Reset file pointer after reading + except Exception as e: + error_msg = f"Error validating image content for {filename}: {str(e)}" + app.logger.error(error_msg) + + if extension == 'png': + app.logger.error(f"PNG DEBUG: Failed to process PNG file: {filename}") + app.logger.error(f"PNG DEBUG: Error details: {str(e)}") + app.logger.error(f"PNG DEBUG: Error type: {type(e).__name__}") + + # Get the full traceback as string and log it + import io + tb_output = io.StringIO() + traceback.print_exc(file=tb_output) + app.logger.error(f"PNG DEBUG: Full traceback for {filename}:\n{tb_output.getvalue()}") + + # Try to manually read the file data and check it + try: + file_content.seek(0) + file_bytes = file_content.read(1024) # Read first KB + file_content.seek(0) # Reset again + + hex_signature = ' '.join([f"{b:02x}" for b in file_bytes[:16]]) + app.logger.error(f"PNG DEBUG: Raw file bytes (first 16): {hex_signature}") + + # Check for common corruption patterns + if not file_bytes.startswith(png_signature): + app.logger.error(f"PNG DEBUG: File doesn't start with PNG signature") + if file_bytes.startswith(b'<'): + app.logger.error(f"PNG DEBUG: File appears to be XML/HTML, not PNG") + elif file_bytes.startswith(b'\xff\xd8'): + app.logger.error(f"PNG DEBUG: File appears to be JPEG, not PNG") + + # Check file size again to make sure it's not empty + file_content.seek(0, os.SEEK_END) + actual_size = file_content.tell() + file_content.seek(0) + if actual_size < 100: # Very small for a PNG + app.logger.error(f"PNG DEBUG: File is suspiciously small: {actual_size} bytes") + except Exception as raw_err: + app.logger.error(f"PNG DEBUG: Error during raw file analysis: {str(raw_err)}") + + traceback.print_exc() + + return False, f"Datei '{filename}' konnte nicht als Bild erkannt werden. Fehler: {str(e)}" + + # Add more content type validations as needed for other file types + + except Exception as e: + app.logger.error(f"Error during content validation for {filename}: {str(e)}") + file_content.seek(0) # Reset file pointer in case of error + # Don't reject the file based on content validation failure alone + + return True, "" + + +def strip_whitespace(value): + """ + Strip leading and trailing whitespace from a string or from each item in a list. + + Args: + value: String or list of strings to strip + + Returns: + String or list of strings with whitespace stripped + """ + if isinstance(value, str): + return value.strip() + elif isinstance(value, list): + return [item.strip() if isinstance(item, str) else item for item in value] + return value + + +def sanitize_form_value(value): + """ + Strip whitespace and escape HTML for a string or each string item in a list. + + Args: + value: String, list of strings, or None + + Returns: + Sanitized string, list of sanitized strings, or the original value if unsupported + """ + value = strip_whitespace(value) + if isinstance(value, str): + return html.escape(value) + if isinstance(value, list): + return [html.escape(item) if isinstance(item, str) else item for item in value] + return value + + +def normalize_isbn(isbn_raw): + """Return only digits/X from ISBN input in canonical uppercase form.""" + if isbn_raw is None: + return '' + normalized = re.sub(r'[^0-9Xx]', '', str(isbn_raw)).upper() + return normalized + + +def is_valid_isbn10(isbn10): + if not re.fullmatch(r'[0-9]{9}[0-9X]', isbn10): + return False + checksum = 0 + for idx, char in enumerate(isbn10): + value = 10 if char == 'X' else int(char) + checksum += value * (10 - idx) + return checksum % 11 == 0 + + +def is_valid_isbn13(isbn13): + if not re.fullmatch(r'[0-9]{13}', isbn13): + return False + checksum = 0 + for idx, char in enumerate(isbn13[:12]): + checksum += int(char) * (1 if idx % 2 == 0 else 3) + 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) + if len(isbn) == 13 and is_valid_isbn13(isbn): + return isbn + if len(isbn) == 10 and is_valid_isbn10(isbn): + return isbn + return '' + + +@app.route('/uploads/') +def uploaded_file(filename): + """ + Serve uploaded files from the uploads directory. + + Args: + filename (str): Name of the file to serve + + Returns: + flask.Response: The requested file or placeholder image if not found + """ + try: + # Check production path first (deployed environment) + prod_path = "/opt/Inventarsystem/Web/uploads" + dev_path = app.config['UPLOAD_FOLDER'] + if os.path.exists(os.path.join(prod_path, filename)): + return send_from_directory(prod_path, filename) + # Then check development path + if os.path.exists(os.path.join(dev_path, filename)): + return send_from_directory(dev_path, filename) + + # Use a placeholder image if file not found - first try SVG, then PNG + svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') + png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') + + if os.path.exists(svg_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.svg') + elif os.path.exists(png_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.png') + + # Default placeholder from static folder + return send_from_directory(app.static_folder, 'favicon.ico') + except Exception as e: + print(f"Error serving file {filename}: {str(e)}") + return Response("Image not found", status=404) + + +@app.route('/thumbnails/') +def thumbnail_file(filename): + """ + Serve thumbnail files from the thumbnails directory. + + Args: + filename (str): Name of the thumbnail file to serve + + Returns: + flask.Response: The requested thumbnail file or placeholder image if not found + """ + try: + # Check production path first + prod_path = "/var/Inventarsystem/Web/thumbnails" + dev_path = app.config['THUMBNAIL_FOLDER'] + if os.path.exists(os.path.join(prod_path, filename)): + return send_from_directory(prod_path, filename) + if os.path.exists(os.path.join(dev_path, filename)): + return send_from_directory(dev_path, filename) + + # Use a placeholder image if file not found - first try SVG, then PNG + svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') + png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') + + if os.path.exists(svg_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.svg') + elif os.path.exists(png_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.png') + else: + return send_from_directory(app.static_folder, 'favicon.ico') + except Exception as e: + print(f"Error serving thumbnail {filename}: {str(e)}") + return Response("Thumbnail not found", status=404) + + +@app.route('/previews/') +def preview_file(filename): + """ + Serve preview files from the previews directory. + + Args: + filename (str): Name of the preview file to serve + + Returns: + flask.Response: The requested preview file or placeholder image if not found + """ + try: + # Check production path first + prod_path = "/var/Inventarsystem/Web/previews" + dev_path = app.config['PREVIEW_FOLDER'] + if os.path.exists(os.path.join(prod_path, filename)): + return send_from_directory(prod_path, filename) + if os.path.exists(os.path.join(dev_path, filename)): + return send_from_directory(dev_path, filename) + + # Use a placeholder image if file not found - first try SVG, then PNG + svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') + png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') + + if os.path.exists(svg_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.svg') + elif os.path.exists(png_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.png') + else: + return send_from_directory(app.static_folder, 'favicon.ico') + except Exception as e: + print(f"Error serving preview {filename}: {str(e)}") + return Response("Preview not found", status=404) + + +# @app.route('/QRCodes/') +# def qrcode_file(filename): +# """ +# Serve QR code files from the QRCodes directory. +# +# Args: +# filename (str): Name of the QR code file to serve +# +# Returns: +# flask.Response: The requested QR code file or placeholder image if not found +# """ +# try: +# # Check production path first +# prod_path = "/var/Inventarsystem/Web/QRCodes" +# dev_path = app.config['QR_CODE_FOLDER'] +# if os.path.exists(os.path.join(prod_path, filename)): +# return send_from_directory(prod_path, filename) +# if os.path.exists(os.path.join(dev_path, filename)): +# return send_from_directory(dev_path, filename) +# +# # Use a placeholder image if file not found - first try SVG, then PNG +# svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') +# png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') +# +# if os.path.exists(svg_placeholder_path): +# return send_from_directory(app.static_folder, 'img/no-image.svg') +# elif os.path.exists(png_placeholder_path): +# return send_from_directory(app.static_folder, 'img/no-image.png') +# else: +# return send_from_directory(app.static_folder, 'favicon.ico') +# except Exception as e: +# print(f"Error serving QR code {filename}: {str(e)}") +# return Response("QR code not found", status=404) + + +@app.route('/') +def catch_all_files(filename): + """ + Fallback route to serve files from various directories. + Tries to find the requested file in known directories. + + Args: + filename (str): Name of the file to serve + + Returns: + flask.Response: The requested file or placeholder image if not found + """ + try: + # Check if the file exists in any of our directories + possible_dirs = [ + app.config['UPLOAD_FOLDER'], + app.config['THUMBNAIL_FOLDER'], + app.config['PREVIEW_FOLDER'], + # app.config['QR_CODE_FOLDER'], # QR Code serving deactivated + os.path.join(BASE_DIR, 'static') + ] + + # Check development paths first + for directory in possible_dirs: + file_path = os.path.join(directory, filename) + if os.path.isfile(file_path): + return send_from_directory(directory, os.path.basename(filename)) + + # Check production paths if available + if os.path.exists("/var/Inventarsystem/Web"): + prod_dirs = [ + "/var/Inventarsystem/Web/uploads", + "/var/Inventarsystem/Web/thumbnails", + "/var/Inventarsystem/Web/previews", + # "/var/Inventarsystem/Web/QRCodes", # QR Code serving deactivated + "/var/Inventarsystem/Web/static" + ] + + for directory in prod_dirs: + file_path = os.path.join(directory, filename) + if os.path.isfile(file_path): + return send_from_directory(directory, os.path.basename(filename)) + + # Check if this looks like an image request + if any(filename.lower().endswith(ext) for ext in ['png', 'jpg', 'jpeg', 'gif', 'svg']): + # Use a placeholder image if file not found - first try SVG, then PNG + svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') + png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') + + if os.path.exists(svg_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.svg') + elif os.path.exists(png_placeholder_path): + return send_from_directory(app.static_folder, 'img/no-image.png') + else: + return send_from_directory(app.static_folder, 'favicon.ico') + + # If we get here, the file wasn't found + return Response(f"File {filename} not found", status=404) + except Exception as e: + print(f"Error in catch-all route for {filename}: {str(e)}") + return Response(f"Error serving file: {str(e)}", status=500) + + +@app.route('/test_connection', methods=['GET']) +def test_connection(): + """ + Test API endpoint to verify the server is running. + + Returns: + dict: Status information including version and status code + """ + return {'status': 'success', 'message': 'Connection successful', 'version': __version__, 'status_code': 200} + + +@app.route('/user_status') +def user_status(): + """ + API endpoint to get the current user's status (username, admin status). + Used by JavaScript in templates to personalize the UI. + + Returns: + JSON: User status information or error if not authenticated + """ + if 'username' in session: + is_admin = us.check_admin(session['username']) + return jsonify({ + 'authenticated': True, + 'username': session['username'], + 'is_admin': is_admin + }) + else: + return jsonify({ + 'authenticated': False, + 'error': 'Not logged in' + }), 401 + + +@app.route('/') +def home(): + """ + Main route for the application homepage. + Redirects to the appropriate view based on user role. + + Returns: + flask.Response: Rendered template or redirect + """ + if 'username' not in session: + flash('Bitte mit registriertem Konto anmelden!', 'error') + return redirect(url_for('login')) + elif not us.check_admin(session['username']): + return render_template( + 'main.html', + username=session['username'], + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, + student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS + ) + else: + return redirect(url_for('home_admin')) + + +@app.route('/home_admin') +def home_admin(): + """ + Admin homepage route. + Only accessible by users with admin privileges. + + Returns: + flask.Response: Rendered template or redirect + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + return render_template( + 'main_admin.html', + username=session['username'], + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, + student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS + ) + + +@app.route('/tutorial') +def tutorial_page(): + """Guided onboarding page (2-5 minutes) for first-time users.""" + if 'username' not in session: + flash('Bitte mit registriertem Konto anmelden!', 'error') + return redirect(url_for('login')) + + return render_template( + 'tutorial.html', + username=session['username'], + is_admin=us.check_admin(session['username']), + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, + student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS + ) + +@app.route('/library') +def library_view(): + """ + Dedicated page for viewing library items (books, CDs, etc.). + Only available when library module is enabled. + Table-only view with customizable filter. + + Returns: + flask.Response: Rendered template or redirect + """ + if 'username' not in session: + flash('Bitte mit registriertem Konto anmelden!', 'error') + return redirect(url_for('login')) + if not cfg.LIBRARY_MODULE_ENABLED: + flash('Bibliotheks-Modul ist deaktiviert.', 'error') + return redirect(url_for('home')) + + return render_template( + 'library_table.html', + username=session['username'], + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + is_admin=us.check_admin(session['username']), + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, + student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS + ) + + +@app.route('/library_loans_admin') +def library_loans_admin(): + """Admin overview for library borrowings and damaged library items.""" + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not cfg.LIBRARY_MODULE_ENABLED: + flash('Bibliotheks-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + def fmt_dt(dt): + try: + return dt.strftime('%d.%m.%Y %H:%M') if dt else '' + except Exception: + return str(dt) if dt else '' + + def fmt_money(value): + return _format_money_value(value) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + ausleihungen_col = db['ausleihungen'] + + library_items = list(items_col.find( + {'ItemType': {'$in': LIBRARY_ITEM_TYPES}}, + {'Name': 1, 'Code_4': 1, 'Anschaffungskosten': 1, 'Condition': 1, 'HasDamage': 1, 'DamageReports': 1, 'Verfuegbar': 1, 'User': 1, 'ItemType': 1, 'Author': 1, 'ISBN': 1} + )) + item_map = {str(item['_id']): item for item in library_items if item.get('_id')} + item_ids = list(item_map.keys()) + + active_records = [] + if item_ids: + active_records = list(ausleihungen_col.find( + {'Item': {'$in': item_ids}, 'Status': {'$in': ['active', 'planned', 'completed']}}, + {'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1, 'Period': 1, 'Notes': 1, 'InvoiceData': 1} + ).sort('Start', -1)) + + active_item_ids = set() + loan_entries = [] + for record in active_records: + item_id = str(record.get('Item') or '') + item_doc = item_map.get(item_id) + if item_id and record.get('Status') == 'active': + active_item_ids.add(item_id) + + if not item_doc: + continue + + invoice_data = record.get('InvoiceData') or {} + condition_value = str(item_doc.get('Condition', '')).strip().lower() + item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(item_doc.get('DamageReports')) + damage_reports = item_doc.get('DamageReports', []) or [] + + loan_entries.append({ + 'id': str(record.get('_id')), + 'item_id': item_id, + 'item_name': item_doc.get('Name', ''), + 'item_code': item_doc.get('Code_4', ''), + 'item_author': item_doc.get('Author', ''), + 'item_isbn': item_doc.get('ISBN', ''), + 'user': record.get('User', ''), + 'status': record.get('Status', ''), + 'start': fmt_dt(record.get('Start')), + 'end': fmt_dt(record.get('End')), + 'period': record.get('Period', ''), + 'notes': record.get('Notes', ''), + 'invoice_number': invoice_data.get('invoice_number', ''), + 'invoice_amount': fmt_money(invoice_data.get('amount')) if invoice_data.get('amount') is not None else fmt_money(item_doc.get('Anschaffungskosten')), + 'invoice_paid': bool(invoice_data.get('paid', False)), + 'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '', + 'has_damage': item_has_damage, + 'damage_count': len(damage_reports), + 'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''), + }) + + damaged_items = [] + for item_doc in library_items: + item_id = str(item_doc.get('_id') or '') + condition_value = str(item_doc.get('Condition', '')).strip().lower() + damage_reports = item_doc.get('DamageReports', []) or [] + item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(damage_reports) + if not item_has_damage or item_id in active_item_ids: + continue + + damaged_items.append({ + 'id': item_id, + 'name': item_doc.get('Name', ''), + 'code': item_doc.get('Code_4', ''), + 'author': item_doc.get('Author', ''), + 'isbn': item_doc.get('ISBN', ''), + 'condition': item_doc.get('Condition', ''), + 'damage_count': len(damage_reports), + 'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''), + 'available': bool(item_doc.get('Verfuegbar', False)), + 'last_updated': fmt_dt(item_doc.get('LastUpdated')), + }) + + return render_template( + 'library_borrowings_admin.html', + loan_entries=loan_entries, + damaged_items=damaged_items, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + ) + except Exception as e: + app.logger.error(f"Error loading library loans admin view: {e}") + flash('Fehler beim Laden der Bibliotheksverwaltung.', 'error') + return redirect(url_for('home_admin')) + finally: + if client: + client.close() + + +@app.route('/api/library_items') +def api_library_items(): + """ + API endpoint to fetch all library items (books, CDs, DVDs, media). + Returns JSON array suitable for table view. + """ + if 'username' not in session: + return jsonify([]), 401 + + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_db = db['items'] + ausleihungen_db = db['ausleihungen'] + + library_items = list(items_db.find({ + 'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']} + })) + + item_ids = [str(item.get('_id')) for item in library_items if item.get('_id')] + active_records = [] + if item_ids: + active_records = list(ausleihungen_db.find( + {'Item': {'$in': item_ids}, 'Status': 'active'}, + {'Item': 1, 'User': 1} + )) + + active_item_ids = set() + active_user_by_item = {} + for rec in active_records: + item_id = str(rec.get('Item') or '') + if not item_id: + continue + active_item_ids.add(item_id) + if item_id not in active_user_by_item: + active_user_by_item[item_id] = rec.get('User', '') + + client.close() + + # Convert ObjectId to string for JSON serialization + for item in library_items: + item_id = str(item['_id']) + item['_id'] = item_id + + condition_value = str(item.get('Condition', '')).strip().lower() + has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed' + has_active_borrow = item_id in active_item_ids + + if has_damage and not has_active_borrow: + item['LibraryDisplayStatus'] = 'damaged' + elif has_active_borrow or item.get('Verfuegbar') is False: + item['LibraryDisplayStatus'] = 'borrowed' + else: + item['LibraryDisplayStatus'] = 'available' + + item['BorrowedBy'] = active_user_by_item.get(item_id) or item.get('User', '') + + return jsonify(library_items), 200 + except Exception as e: + print(f"Error fetching library items: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/library_scan_action', methods=['POST']) +def api_library_scan_action(): + """ + Scan-based library workflow: + - scan student card id + - scan media code (ISBN/Code_4) + - borrow if available, otherwise return (toggle behavior) + """ + if 'username' not in session: + return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401 + if not cfg.LIBRARY_MODULE_ENABLED: + return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403 + if not cfg.STUDENT_CARDS_MODULE_ENABLED: + return jsonify({'ok': False, 'message': 'Schülerausweis-Modul ist deaktiviert.'}), 403 + + payload = request.get_json(silent=True) or {} + student_card_id = us.normalize_student_card_id(payload.get('student_card_id') or payload.get('card_id')) + item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip() + duration_raw = str(payload.get('borrow_duration_days') or '').strip() + + if not student_card_id: + return jsonify({'ok': False, 'message': 'Schülerausweis-ID fehlt.'}), 400 + if not item_code_raw: + return jsonify({'ok': False, 'message': 'Mediencode fehlt.'}), 400 + + normalized_isbn = normalize_and_validate_isbn(item_code_raw) + normalized_code = item_code_raw.upper() + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + student_cards_col = db['student_cards'] + items_col = db['items'] + ausleihungen_col = db['ausleihungen'] + + card_doc = student_cards_col.find_one({'AusweisId': student_card_id}) + if not card_doc: + return jsonify({'ok': False, 'message': 'Ungültige Schülerausweis-ID.'}), 404 + + query_or = [ + {'Code_4': item_code_raw}, + {'Code_4': normalized_code}, + ] + if normalized_isbn: + query_or.append({'ISBN': normalized_isbn}) + + item_doc = items_col.find_one({ + 'ItemType': {'$in': LIBRARY_ITEM_TYPES}, + '$or': query_or + }) + + if not item_doc: + return jsonify({'ok': False, 'message': 'Kein Bibliotheksmedium für diesen Code gefunden.'}), 404 + + item_id = str(item_doc['_id']) + borrower_name = card_doc.get('SchülerName') or f"Ausweis {student_card_id}" + now = datetime.datetime.now() + + if item_doc.get('Verfuegbar', True): + borrow_duration_days = None + if duration_raw: + try: + parsed_duration = int(duration_raw) + if 1 <= parsed_duration <= cfg.STUDENT_MAX_BORROW_DAYS: + borrow_duration_days = parsed_duration + else: + return jsonify({ + 'ok': False, + 'message': f'Ausleihdauer muss zwischen 1 und {cfg.STUDENT_MAX_BORROW_DAYS} Tagen liegen.' + }), 400 + except ValueError: + return jsonify({'ok': False, 'message': 'Ungültige Ausleihdauer.'}), 400 + else: + try: + card_default = int(card_doc.get('StandardAusleihdauer', cfg.STUDENT_DEFAULT_BORROW_DAYS)) + except (TypeError, ValueError): + card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS + borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS)) + + end_date = now + datetime.timedelta(days=borrow_duration_days) if borrow_duration_days else None + it.update_item_status(item_id, False, borrower_name) + au.add_ausleihung(item_id, borrower_name, now, end_date=end_date) + + return jsonify({ + 'ok': True, + 'action': 'borrowed', + 'item_id': item_id, + 'item_name': item_doc.get('Name', ''), + 'student_card_id': student_card_id, + 'borrower': borrower_name, + 'borrow_duration_days': borrow_duration_days, + 'message': f"{item_doc.get('Name', 'Medium')} wurde ausgeliehen." + }), 200 + + # Toggle back: item is currently borrowed -> return + current_borrower = str(item_doc.get('User') or '').strip() + if current_borrower and current_borrower != borrower_name and not us.check_admin(session['username']): + return jsonify({ + 'ok': False, + 'message': f"Medium ist aktuell an '{current_borrower}' ausgeliehen und kann mit diesem Ausweis nicht zurückgegeben werden." + }), 409 + + update_result = ausleihungen_col.update_many( + {'Item': item_id, 'Status': 'active'}, + {'$set': { + 'Status': 'completed', + 'End': now, + 'LastUpdated': now + }} + ) + it.update_item_status(item_id, True, borrower_name) + + return jsonify({ + 'ok': True, + 'action': 'returned', + 'item_id': item_id, + 'item_name': item_doc.get('Name', ''), + 'student_card_id': student_card_id, + 'borrower': borrower_name, + 'completed_records': update_result.modified_count, + 'message': f"{item_doc.get('Name', 'Medium')} wurde zurückgegeben." + }), 200 + except Exception as e: + app.logger.error(f"Error in library scan action: {e}") + return jsonify({'ok': False, 'message': 'Fehler beim Verarbeiten des Scan-Vorgangs.'}), 500 + finally: + if client: + client.close() + + +@app.route('/api/item_detail/') +def api_item_detail(item_id): + """ + API endpoint to fetch detail view HTML for a library item. + """ + if 'username' not in session: + return jsonify({'error': 'Unauthorized'}), 401 + + try: + item = it.get_item(item_id) + if not item: + return jsonify({'error': 'Item not found'}), 404 + + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen_col = db['ausleihungen'] + + active_borrow = ausleihungen_col.find_one( + {'Item': str(item.get('_id')), 'Status': 'active'}, + {'User': 1} + ) + client.close() + + condition_value = str(item.get('Condition', '')).strip().lower() + has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed' + if has_damage and not active_borrow: + status_label = 'Defekt/Zerstört' + elif item.get('Verfuegbar') is False or active_borrow: + status_label = 'Ausgeliehen' + else: + status_label = 'Verfügbar' + + borrower_value = '' + if active_borrow: + borrower_value = active_borrow.get('User', '') + elif item.get('User'): + borrower_value = item.get('User') + + # Basic detail HTML + detail_html = f""" +

{html.escape(item.get('Name', 'Untitled'))}

+

Autor/Künstler: {html.escape(item.get('Autor', item.get('Author', '-')))}

+

ISBN: {html.escape(item.get('ISBN', item.get('Code4', '-')))}

+

Beschreibung: {html.escape(item.get('Beschreibung', '-'))}

+

Status: {html.escape(status_label)}

+ {f'

Ausgeliehen von: {html.escape(str(borrower_value))}

' if borrower_value and status_label == 'Ausgeliehen' else ''} + """ + return detail_html, 200 + except Exception as e: + print(f"Error fetching item detail: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/library_item//update', methods=['POST']) +def api_library_item_update(item_id): + """Admin-only API to edit library item core fields from the library table view.""" + if 'username' not in session: + return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401 + if not us.check_admin(session['username']): + return jsonify({'ok': False, 'message': 'Administratorrechte erforderlich.'}), 403 + if not cfg.LIBRARY_MODULE_ENABLED: + return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403 + + payload = request.get_json(silent=True) or {} + + name = sanitize_form_value(payload.get('name')) + description = sanitize_form_value(payload.get('beschreibung')) + location = sanitize_form_value(payload.get('ort')) + author = sanitize_form_value(payload.get('autor')) + media_type = sanitize_form_value(payload.get('item_type')).lower() or 'book' + code_4 = sanitize_form_value(payload.get('code_4')) + isbn_raw = sanitize_form_value(payload.get('isbn')) + + if not name or not description or not location: + return jsonify({'ok': False, 'message': 'Name, Ort und Beschreibung sind erforderlich.'}), 400 + + if media_type not in LIBRARY_ITEM_TYPES: + return jsonify({'ok': False, 'message': 'Ungültiger Medientyp.'}), 400 + + normalized_isbn = '' + if isbn_raw: + normalized_isbn = normalize_and_validate_isbn(isbn_raw) + if not normalized_isbn: + return jsonify({'ok': False, 'message': 'Ungültige ISBN (nur ISBN-10/13).'}), 400 + + if code_4 and not it.is_code_unique(code_4, exclude_id=item_id): + return jsonify({'ok': False, 'message': 'Der Code wird bereits verwendet.'}), 409 + + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + + current = items_col.find_one({'_id': ObjectId(item_id)}) + if not current: + client.close() + return jsonify({'ok': False, 'message': 'Element nicht gefunden.'}), 404 + if current.get('ItemType') not in LIBRARY_ITEM_TYPES: + client.close() + return jsonify({'ok': False, 'message': 'Element ist kein Bibliotheksmedium.'}), 400 + + update_doc = { + 'Name': name, + 'Beschreibung': description, + 'Ort': location, + 'Autor': author, + 'Code_4': code_4, + 'ItemType': media_type, + 'LastUpdated': datetime.datetime.now() + } + + if normalized_isbn: + update_doc['ISBN'] = normalized_isbn + elif 'ISBN' in current: + update_doc['ISBN'] = '' + + items_col.update_one({'_id': ObjectId(item_id)}, {'$set': update_doc}) + client.close() + + return jsonify({'ok': True, 'message': 'Bibliotheksmedium aktualisiert.'}), 200 + except Exception as e: + app.logger.error(f"Error updating library item {item_id}: {e}") + return jsonify({'ok': False, 'message': 'Fehler beim Aktualisieren des Bibliotheksmediums.'}), 500 + + +@app.route('/upload_admin') +def upload_admin(): + """ + Admin upload page route. + Only accessible by users with admin privileges. + Supports duplication by passing duplicate_from parameter. + + Returns: + flask.Response: Rendered template or redirect + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Check if this is a duplication request + duplicate_from = request.args.get('duplicate_from') + duplicate_flag = request.args.get('duplicate') # Check for sessionStorage-based duplication + duplicate_data = None + + # Handle the old method (duplicate_from parameter with item ID) + if duplicate_from: + try: + original_item = it.get_item(duplicate_from) + if original_item: + # Enhanced debug logging for images + images = original_item.get('Images', []) + print(f"DEBUG: Original item: {original_item.get('_id')} has these images: {images}") + print(f"DEBUG: Images type: {type(images)}, count: {len(images) if isinstance(images, list) else 'not a list'}") + + duplicate_data = { + 'name': original_item.get('Name', ''), + 'description': original_item.get('Beschreibung', ''), + 'location': original_item.get('Ort', ''), + 'room': original_item.get('Raum', ''), + 'category': original_item.get('Kategorie', ''), + 'year': original_item.get('Anschaffungsjahr', ''), + 'cost': original_item.get('Anschaffungskosten', ''), + 'filter1': original_item.get('Filter1', ''), + 'filter2': original_item.get('Filter2', ''), + 'filter3': original_item.get('Filter3', ''), + 'images': original_item.get('Images', []), + 'original_id': duplicate_from + } + # Copy all filter fields (Filter1_1 through Filter3_5) + for i in range(1, 4): # Filter1, Filter2, Filter3 + for j in range(1, 6): # _1 through _5 + filter_key = f'Filter{i}_{j}' + if filter_key in original_item: + duplicate_data[f'filter{i}_{j}'] = original_item[filter_key] + + flash('Element wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info') + else: + flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error') + except Exception as e: + print(f"Error loading item for duplication: {e}") + flash('Fehler beim Laden der Duplizierungsdaten.', 'error') + + # Handle the new method (sessionStorage-based duplication) + elif duplicate_flag == 'true': + # No server-side processing needed - JavaScript will handle sessionStorage data + # Just indicate that duplication mode is active + flash('Element wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info') + + return render_template( + 'upload_admin.html', + username=session['username'], + duplicate_data=duplicate_data, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + show_library_features=False, + upload_mode='item', + page_title='Artikel hochladen', + back_target='home_admin' + ) + + +@app.route('/library_admin') +def library_admin(): + """ + Dedicated admin page for library/book uploads with ISBN scanning. + Only accessible by admins and only when the library module is enabled. + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not cfg.LIBRARY_MODULE_ENABLED: + flash('Bibliotheks-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + duplicate_from = request.args.get('duplicate_from') + duplicate_flag = request.args.get('duplicate') + duplicate_data = None + + if duplicate_from: + try: + original_item = it.get_item(duplicate_from) + if original_item: + duplicate_data = { + 'name': original_item.get('Name', ''), + 'description': original_item.get('Beschreibung', ''), + 'location': original_item.get('Ort', ''), + 'room': original_item.get('Raum', ''), + 'category': original_item.get('Kategorie', ''), + 'year': original_item.get('Anschaffungsjahr', ''), + 'cost': original_item.get('Anschaffungskosten', ''), + 'filter1': original_item.get('Filter1', ''), + 'filter2': original_item.get('Filter2', ''), + 'filter3': original_item.get('Filter3', ''), + 'images': original_item.get('Images', []), + 'original_id': duplicate_from + } + for i in range(1, 4): + for j in range(1, 6): + filter_key = f'Filter{i}_{j}' + if filter_key in original_item: + duplicate_data[f'filter{i}_{j}'] = original_item[filter_key] + flash('Buch wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info') + else: + flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error') + except Exception as e: + print(f"Error loading item for duplication: {e}") + flash('Fehler beim Laden der Duplizierungsdaten.', 'error') + elif duplicate_flag == 'true': + flash('Buch wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info') + + return render_template( + 'upload_admin.html', + username=session['username'], + duplicate_data=duplicate_data, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + show_library_features=True, + upload_mode='library', + page_title='Bücher hochladen', + back_target='home_admin' + ) + + +@app.route('/student_cards_admin', methods=['GET', 'POST']) +def student_cards_admin(): + """ + Admin page for managing student library cards (Schülerausweise). + Only accessible by admins and only when the student cards module is enabled. + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not cfg.STUDENT_CARDS_MODULE_ENABLED: + flash('Schülerausweis-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + student_cards = db['student_cards'] + + edit_mode = False + form_data = {} + + # Handle GET request to edit a card + edit_id = request.args.get('edit') + if edit_id: + try: + card = student_cards.find_one({'_id': ObjectId(edit_id)}) + if card: + edit_mode = True + form_data = { + 'card_id': str(card['_id']), + 'ausweis_id': card.get('AusweisId', ''), + 'student_name': card.get('SchülerName', ''), + 'default_borrow_days': card.get('StandardAusleihdauer', 14), + 'class_name': card.get('Klasse', ''), + 'notes': card.get('Notizen', '') + } + except Exception as e: + print(f"Error loading student card for edit: {e}") + flash('Fehler beim Laden des Ausweises.', 'error') + + # Handle POST request (add or edit) + if request.method == 'POST': + action = request.form.get('action', 'add') + ausweis_id = request.form.get('ausweis_id', '').strip().upper() + student_name = request.form.get('student_name', '').strip() + default_borrow_days = request.form.get('default_borrow_days', 14) + class_name = request.form.get('class_name', '').strip() + notes = request.form.get('notes', '').strip() + + if action == 'delete': + try: + card_id = request.form.get('card_id') + student_cards.delete_one({'_id': ObjectId(card_id)}) + flash('Ausweis wurde gelöscht.', 'success') + except Exception as e: + print(f"Error deleting student card: {e}") + flash('Fehler beim Löschen des Ausweises.', 'error') + + elif action == 'edit': + if not ausweis_id or not student_name: + flash('Bitte Ausweis-ID und Schülername angeben.', 'error') + else: + try: + card_id = request.form.get('card_id') + # Check if new ID already exists (and it's not the same card) + existing = student_cards.find_one({'AusweisId': ausweis_id, '_id': {'$ne': ObjectId(card_id)}}) + if existing: + flash('Diese Ausweis-ID existiert bereits.', 'error') + else: + student_cards.update_one( + {'_id': ObjectId(card_id)}, + {'$set': { + 'AusweisId': ausweis_id, + 'SchülerName': student_name, + 'StandardAusleihdauer': int(default_borrow_days), + 'Klasse': class_name, + 'Notizen': notes, + 'Aktualisiert': datetime.datetime.now() + }} + ) + flash('Ausweis wurde aktualisiert.', 'success') + return redirect(url_for('student_cards_admin')) + except Exception as e: + print(f"Error updating student card: {e}") + flash('Fehler beim Aktualisieren des Ausweises.', 'error') + + elif action == 'add': + if not ausweis_id or not student_name: + flash('Bitte Ausweis-ID und Schülername angeben.', 'error') + else: + # Check if ID already exists + existing = student_cards.find_one({'AusweisId': ausweis_id}) + if existing: + flash('Diese Ausweis-ID existiert bereits.', 'error') + else: + try: + student_cards.insert_one({ + 'AusweisId': ausweis_id, + 'SchülerName': student_name, + 'StandardAusleihdauer': int(default_borrow_days), + 'Klasse': class_name, + 'Notizen': notes, + 'Erstellt': datetime.datetime.now() + }) + flash('Neuer Ausweis wurde hinzugefügt.', 'success') + return redirect(url_for('student_cards_admin')) + except Exception as e: + print(f"Error adding student card: {e}") + flash('Fehler beim Hinzufügen des Ausweises.', 'error') + + # Get all student cards + all_cards = list(student_cards.find().sort('AusweisId', 1)) + client.close() + + return render_template( + 'student_cards_admin.html', + username=session['username'], + student_cards=all_cards, + edit_mode=edit_mode, + form_data=form_data, + config={'default': cfg.STUDENT_DEFAULT_BORROW_DAYS}, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED + ) + + +@app.route('/student_cards_print', methods=['GET']) +def student_cards_print(): + """ + Generate a printable template for all student library cards (Schülerausweise). + Only accessible by admins and only when the student cards module is enabled. + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not cfg.STUDENT_CARDS_MODULE_ENABLED: + flash('Schülerausweis-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + student_cards = db['student_cards'] + + # Get all student cards sorted by ID + all_cards = list(student_cards.find().sort('AusweisId', 1)) + client.close() + + return render_template( + 'student_cards_print.html', + student_cards=all_cards, + current_datetime=datetime.datetime.now() + ) + + +@app.route('/student_card_barcode_print', methods=['GET']) +def student_card_barcode_print(): + """ + Generate a barcode-based print template for student library cards. + Only accessible by admins and only when the student cards module is enabled. + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not cfg.STUDENT_CARDS_MODULE_ENABLED: + flash('Schülerausweis-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + student_cards = db['student_cards'] + + # Get all student cards sorted by ID + all_cards = list(student_cards.find().sort('AusweisId', 1)) + client.close() + + return render_template( + 'student_card_barcode_print.html', + student_cards=all_cards, + current_datetime=datetime.datetime.now(), + download_link=url_for('student_card_barcode_download') + ) + + +@app.route('/student_card_barcode_download', methods=['GET']) +def student_card_barcode_download(): + """ + Download PDF with all student card barcodes (simplified version). + """ + if 'username' not in session: + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Zugriff verweigert.', 'error') + return redirect(url_for('login')) + if not cfg.STUDENT_CARDS_MODULE_ENABLED: + flash('Schülerausweis-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.lib.units import mm + from reportlab.lib.colors import HexColor, white, black + from io import BytesIO + import barcode + from barcode.writer import ImageWriter + import tempfile + import os + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + student_cards = db['student_cards'] + all_cards = list(student_cards.find().sort('AusweisId', 1)) + client.close() + + pdf_buffer = BytesIO() + c = canvas.Canvas(pdf_buffer, pagesize=A4) + + page_width, page_height = A4 + margin = 10 * mm + card_width = 88 * mm + card_height = 56 * mm + cols = 2 + gap_x = 8 * mm + gap_y = 8 * mm + x_positions = [margin, margin + card_width + gap_x] + y_start = page_height - margin + y_pos = y_start + col_idx = 0 + + # Professional color palette + header_color = HexColor("#0F172A") # Sehr dunkles blau + accent_color = HexColor("#2563EB") # Helles blau + light_bg = HexColor("#F8FAFC") # Sehr heller grau + card_bg_color = HexColor("#FFFFFF") # Weiß + text_dark = HexColor("#1E293B") # Dunkler text + text_gray = HexColor("#64748B") # Grauer text + + for i, card in enumerate(all_cards): + if col_idx == 0 and i > 0: + y_pos -= (card_height + gap_y) + + # New page if needed + if y_pos - card_height < margin: + c.showPage() + y_pos = y_start + col_idx = 0 + + x_pos = x_positions[col_idx] + + # Card shadow effect (unter border) + c.setFillColor(HexColor("#E2E8F0")) + c.rect(x_pos + 0.5*mm, y_pos - card_height - 0.5*mm, card_width, card_height, fill=1, stroke=0) + + # Card background + c.setLineWidth(1) + c.setFillColor(card_bg_color) + c.setStrokeColor(HexColor("#CBD5E1")) + c.rect(x_pos, y_pos - card_height, card_width, card_height, fill=1, stroke=1) + + # Left info section (38mm) + info_width = 38 * mm + c.setFillColor(light_bg) + c.rect(x_pos, y_pos - card_height, info_width, card_height, fill=1, stroke=0) + + # Header bar + c.setFillColor(header_color) + c.rect(x_pos, y_pos - 10*mm, card_width, 10*mm, fill=1, stroke=0) + + # Header accent line + c.setStrokeColor(accent_color) + c.setLineWidth(2) + c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm) + + # "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") + + # Student name - large and bold + c.setFillColor(text_dark) + c.setFont("Helvetica-Bold", 10) + name = card['SchülerName'][:20] + c.drawString(x_pos + 3*mm, y_pos - 14*mm, name) + + # ID with label + c.setFont("Helvetica", 8) + c.setFillColor(text_gray) + c.drawString(x_pos + 3*mm, y_pos - 18*mm, "Ausweis ID:") + c.setFillColor(text_dark) + c.setFont("Helvetica-Bold", 9) + c.drawString(x_pos + 3*mm, y_pos - 21*mm, str(card['AusweisId'])) + + # Class with label + if card.get('Klasse'): + c.setFont("Helvetica", 8) + c.setFillColor(text_gray) + c.drawString(x_pos + 3*mm, y_pos - 25*mm, "Klasse:") + c.setFillColor(text_dark) + c.setFont("Helvetica-Bold", 9) + c.drawString(x_pos + 3*mm, y_pos - 28*mm, card['Klasse']) + + # Right barcode section with border highlight + barcode_x_start = x_pos + info_width + 1*mm + c.setFillColor(accent_color) + c.setLineWidth(0) + c.rect(barcode_x_start - 1*mm, y_pos - card_height, + card_width - info_width + 1*mm, 2*mm, fill=1, stroke=0) + + # Generate and add larger barcode + try: + temp_dir = tempfile.gettempdir() + barcode_path = os.path.join(temp_dir, f"barcode_{card['AusweisId']}") + + # Generate barcode with higher module width for better scanning + barcode_obj = barcode.get('code128', str(card['AusweisId']), writer=ImageWriter()) + barcode_obj.save(barcode_path) + barcode_file = f"{barcode_path}.png" + + if os.path.exists(barcode_file): + # Larger barcode taking up most of right section + barcode_width = (card_width - info_width - 4*mm) + barcode_height = 16*mm + barcode_y = y_pos - card_height + (card_height - barcode_height) / 2 + 2*mm + + c.drawImage(barcode_file, + barcode_x_start + 1*mm, + barcode_y, + width=barcode_width, + height=barcode_height, + preserveAspectRatio=True) + os.remove(barcode_file) + else: + raise Exception("Barcode file not created") + except Exception as e: + c.setFont("Helvetica", 7) + c.setFillColor(HexColor("#DC2626")) + c.drawString(barcode_x_start + 2*mm, y_pos - card_height + 20*mm, "⚠️ Barcode Error") + + col_idx += 1 + if col_idx >= cols: + col_idx = 0 + + c.save() + pdf_buffer.seek(0) + + return send_file( + pdf_buffer, + mimetype='application/pdf', + as_attachment=True, + download_name=f'schuelerausweise_all_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf' + ) + except Exception as e: + flash(f'Fehler beim PDF-Download: {str(e)}', 'error') + return redirect(url_for('student_cards_admin')) + + +@app.route('/student_card_single_barcode_download/', methods=['GET']) +def student_card_single_barcode_download(card_id): + """ + Download PDF with single student card barcode. + """ + if 'username' not in session: + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Zugriff verweigert.', 'error') + return redirect(url_for('login')) + if not cfg.STUDENT_CARDS_MODULE_ENABLED: + flash('Schülerausweis-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.lib.units import mm + from reportlab.lib.colors import HexColor, white, black + from io import BytesIO + from bson.objectid import ObjectId + import barcode + from barcode.writer import ImageWriter + import tempfile + import os + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + student_cards = db['student_cards'] + card = student_cards.find_one({'_id': ObjectId(card_id)}) + client.close() + + if not card: + flash('Ausweis nicht gefunden.', 'error') + return redirect(url_for('student_cards_admin')) + + pdf_buffer = BytesIO() + c = canvas.Canvas(pdf_buffer, pagesize=A4) + + page_width, page_height = A4 + margin = 20 * mm + card_width = 105 * mm + card_height = 70 * mm + x_pos = (page_width - card_width) / 2 + y_pos = (page_height - card_height) / 2 + + # Professional color palette + header_color = HexColor("#0F172A") # Sehr dunkles blau + accent_color = HexColor("#2563EB") # Helles blau + light_bg = HexColor("#F8FAFC") # Sehr heller grau + card_bg_color = HexColor("#FFFFFF") # Weiß + text_dark = HexColor("#1E293B") # Dunkler text + text_gray = HexColor("#64748B") # Grauer text + + # Card shadow effect + c.setFillColor(HexColor("#E2E8F0")) + c.rect(x_pos + 1*mm, y_pos - card_height - 1*mm, card_width, card_height, fill=1, stroke=0) + + # Card background + c.setLineWidth(1.5) + c.setFillColor(card_bg_color) + c.setStrokeColor(HexColor("#CBD5E1")) + c.rect(x_pos, y_pos, card_width, card_height, fill=1, stroke=1) + + # Left info section (50mm) + info_width = 50 * mm + c.setFillColor(light_bg) + c.rect(x_pos, y_pos - card_height, info_width, card_height, fill=1, stroke=0) + + # Vertical divider line + c.setStrokeColor(accent_color) + c.setLineWidth(3) + c.line(x_pos + info_width, y_pos - card_height, x_pos + info_width, y_pos) + + # Header bar + c.setFillColor(header_color) + c.rect(x_pos, y_pos - 10*mm, card_width, 10*mm, fill=1, stroke=0) + + # Header accent line + c.setStrokeColor(accent_color) + c.setLineWidth(2.5) + c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm) + + # "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") + + # Student name - large and prominent + c.setFillColor(text_dark) + c.setFont("Helvetica-Bold", 12) + name = card['SchülerName'][:25] + c.drawString(x_pos + 4*mm, y_pos - 16*mm, name) + + # ID section with label + c.setFont("Helvetica", 9) + c.setFillColor(text_gray) + c.drawString(x_pos + 4*mm, y_pos - 21*mm, "Ausweis-ID:") + + c.setFillColor(text_dark) + c.setFont("Helvetica-Bold", 11) + c.drawString(x_pos + 4*mm, y_pos - 25*mm, str(card['AusweisId'])) + + # Class section + if card.get('Klasse'): + c.setFont("Helvetica", 9) + c.setFillColor(text_gray) + c.drawString(x_pos + 4*mm, y_pos - 30*mm, "Klasse:") + + c.setFillColor(text_dark) + c.setFont("Helvetica-Bold", 11) + c.drawString(x_pos + 4*mm, y_pos - 34*mm, card['Klasse']) + + # Barcode section - right side with blue accent + barcode_x_start = x_pos + info_width + 3*mm + c.setFillColor(accent_color) + c.setLineWidth(0) + c.rect(x_pos + info_width, y_pos - card_height, + card_width - info_width, 3*mm, fill=1, stroke=0) + + # Generate and add large barcode + try: + temp_dir = tempfile.gettempdir() + barcode_path = os.path.join(temp_dir, f"barcode_{card['AusweisId']}") + + # Generate barcode with better sizing + barcode_obj = barcode.get('code128', str(card['AusweisId']), writer=ImageWriter()) + barcode_obj.save(barcode_path) + barcode_file = f"{barcode_path}.png" + + if os.path.exists(barcode_file): + # Large barcode on right side + barcode_width = (card_width - info_width - 8*mm) + barcode_height = 20*mm + barcode_y_offset = (card_height - 10*mm - barcode_height) / 2 + + c.drawImage(barcode_file, + barcode_x_start, + y_pos - card_height + barcode_y_offset + 5*mm, + width=barcode_width, + height=barcode_height, + preserveAspectRatio=True) + os.remove(barcode_file) + else: + raise Exception("Barcode file not created") + except Exception as e: + c.setFont("Helvetica", 9) + c.setFillColor(HexColor("#DC2626")) + c.drawString(barcode_x_start + 2*mm, y_pos - card_height + 25*mm, "⚠️ Barcode Error") + + # Bottom info line + c.setStrokeColor(HexColor("#CBD5E1")) + c.setLineWidth(0.5) + c.line(x_pos, y_pos - card_height + 3*mm, x_pos + card_width, y_pos - card_height + 3*mm) + + c.save() + pdf_buffer.seek(0) + + return send_file( + pdf_buffer, + mimetype='application/pdf', + as_attachment=True, + download_name=f'ausweis_{card["AusweisId"]}.pdf' + ) + except Exception as e: + flash(f'Fehler beim PDF-Download: {str(e)}', 'error') + return redirect(url_for('student_cards_admin')) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + """ + User login route. + Authenticates users and redirects to appropriate homepage based on role. + + Returns: + flask.Response: Rendered template or redirect + """ + if 'username' in session: + return redirect(url_for('home')) + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + if not username or not password: + flash('Bitte alle Felder ausfüllen', 'error') + return redirect(url_for('login')) + + user = us.check_nm_pwd(username, password) + + if user: + session['username'] = username + is_admin_user = bool(user.get('Admin', False)) + session['admin'] = is_admin_user + session['is_admin'] = is_admin_user + if is_admin_user: + return redirect(url_for('home_admin')) + else: + return redirect(url_for('home')) + else: + flash('Ungültige Anmeldedaten', 'error') + get_flashed_messages() + return render_template('login.html') + + +@app.route('/impressum') +def impressum(): + """ + Impressum route. + + Returns: + flask.Response: Redirect to impressum + """ + return render_template('impressum.html') + +@app.route('/license') +def license(): + """ + License information route. + Displays the Apache 2.0 license information. + + Returns: + flask.Response: Rendered license template + """ + return render_template('license.html') + +@app.route('/change_password', methods=['GET', 'POST']) +def change_password(): + """ + Change password route. + Allows users to change their password if logged in. + + Returns: + flask.Response: Rendered form or redirect after password change + """ + if 'username' not in session: + flash('Sie müssen angemeldet sein, um Ihr Passwort zu ändern.', 'error') + return redirect(url_for('login')) + + if request.method == 'POST': + current_password = html.escape(request.form.get('current_password')) + new_password = html.escape(request.form.get('new_password')) + confirm_password = html.escape(request.form.get('confirm_password')) + + # Validate inputs + if not all([current_password, new_password, confirm_password]): + flash('Bitte füllen Sie alle Felder aus.', 'error') + return render_template('change_password.html') + + if new_password != confirm_password: + flash('Die neuen Passwörter stimmen nicht überein.', 'error') + return render_template('change_password.html') + + # Verify current password + user = us.check_nm_pwd(session['username'], current_password) + if not user: + flash('Das aktuelle Passwort ist nicht korrekt.', 'error') + return render_template('change_password.html') + + # Check password strength + if not us.check_password_strength(new_password): + flash('Das neue Passwort ist zu schwach. Es sollte mindestens 6 Zeichen lang sein.', 'error') + return render_template('change_password.html') + + # Update the password + if us.update_password(session['username'], new_password): + flash('Ihr Passwort wurde erfolgreich geändert.', 'success') + return redirect(url_for('home')) + else: + flash('Fehler beim Ändern des Passworts. Bitte versuchen Sie es später erneut.', 'error') + + return render_template('change_password.html') + +@app.route('/logout') +def logout(): + """ + User logout route. + Removes user session data and redirects to login. + + Returns: + flask.Response: Redirect to login page + """ + session.pop('username', None) + session.pop('admin', None) + session.pop('is_admin', None) + return redirect(url_for('login')) + + +@app.route('/get_items', methods=['GET']) +def get_items(): + """Return items plus merged favorites (session + DB) and per-item favorite flag.""" + try: + username = session.get('username') + # Merge DB favorites into session if logged in + if username: + try: + db_favs = set(us.get_favorites(username)) + session_favs = set(session.get('favorites', [])) + merged = list(db_favs.union(session_favs)) + session['favorites'] = merged + except Exception as fav_err: + app.logger.warning(f"Could not merge DB favorites: {fav_err}") + favorites = set(session.get('favorites', [])) + + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + items_cur = items_col.find({ + 'IsGroupedSubItem': {'$ne': True}, + 'ItemType': {'$nin': LIBRARY_ITEM_TYPES} + }) + items = [] + for itm in items_cur: + item_id_str = str(itm['_id']) + grouped_children = list(items_col.find({ + 'ParentItemId': item_id_str, + 'IsGroupedSubItem': True + })) + grouped_count = 1 + len(grouped_children) + + grouped_units = [itm] + grouped_children + available_units = [] + grouped_all_codes = [] + for unit in grouped_units: + unit_code = unit.get('Code_4') + if unit_code is not None and str(unit_code).strip() != '': + grouped_all_codes.append(str(unit_code).strip()) + + if unit.get('Verfuegbar', True): + unit_id = str(unit['_id']) if not isinstance(unit['_id'], str) else unit['_id'] + code = unit.get('Code_4') or '-' + available_units.append({ + 'id': unit_id, + 'code': code, + 'label': f"{code} ({unit.get('Name', 'Item')})" + }) + + itm['_id'] = item_id_str + itm['GroupedDisplayCount'] = grouped_count + itm['AvailableGroupedCount'] = len(available_units) + itm['GroupedAvailableUnits'] = available_units + itm['GroupedAllCodes'] = grouped_all_codes + if grouped_count > 1: + itm['Verfuegbar'] = len(available_units) > 0 + itm['is_favorite'] = item_id_str in favorites + items.append(itm) + return jsonify({'items': items, 'favorites': list(favorites)}) + except Exception as e: + return jsonify({'items': [], 'error': str(e)}), 500 + + +@app.route('/get_item/') +def get_item_json(id): + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + item = db['items'].find_one({'_id': ObjectId(id)}) + if not item: + return jsonify({'error': 'not found'}), 404 + item['_id'] = str(item['_id']) + return jsonify(item) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/booking_conflicts') +def api_booking_conflicts(): + """ + Returns all active bookings that have a detected conflict + (i.e. the item was already borrowed when the planned booking activated). + Regular users see only their own conflicts; admins see all. + """ + if 'username' not in session: + return jsonify({'error': 'Not authenticated'}), 401 + try: + is_admin = us.check_admin(session['username']) + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + + query = {'ConflictDetected': True, 'Status': 'active'} + if not is_admin: + query['User'] = session['username'] + + conflicts = list(ausleihungen.find(query)) + result = [] + for c in conflicts: + item_doc = it.get_item(c.get('Item')) + item_name = item_doc.get('Name', c.get('Item', '?')) if item_doc else c.get('Item', '?') + conflict_at = c.get('ConflictAt') + if isinstance(conflict_at, datetime.datetime): + conflict_at = conflict_at.strftime('%Y-%m-%d %H:%M') + result.append({ + 'id': str(c['_id']), + 'Item': item_name, + 'User': c.get('User', '?'), + 'ConflictNote': c.get('ConflictNote', ''), + 'ConflictAt': conflict_at, + 'Status': c.get('Status', ''), + }) + client.close() + return jsonify({'conflicts': result, 'count': len(result)}) + except Exception as e: + return jsonify({'error': str(e), 'conflicts': []}), 500 + +"""Favorites management endpoints (persistent + session cache).""" +def _ensure_session_favs(): + if 'favorites' not in session: + session['favorites'] = [] + +@app.route('/favorites', methods=['GET']) +def list_favorites(): + _ensure_session_favs() + username = session.get('username') + if username: + try: + db_favs = set(us.get_favorites(username)) + merged = list(db_favs.union(set(session['favorites']))) + session['favorites'] = merged + except Exception as e: + app.logger.warning(f"Listing favorites merge failed: {e}") + return jsonify({'ok': True, 'favorites': session['favorites']}) + +@app.route('/favorites/', methods=['POST']) +def add_fav(item_id): + _ensure_session_favs() + if item_id not in session['favorites']: + session['favorites'].append(item_id) + username = session.get('username') + if username: + try: + us.add_favorite(username, item_id) + except Exception as e: + app.logger.warning(f"Persist add favorite failed: {e}") + session.modified = True + return jsonify({'ok': True, 'favorites': session['favorites']}) + +@app.route('/favorites/', methods=['DELETE']) +def remove_fav(item_id): + _ensure_session_favs() + session['favorites'] = [f for f in session['favorites'] if f != item_id] + username = session.get('username') + if username: + try: + us.remove_favorite(username, item_id) + except Exception as e: + app.logger.warning(f"Persist remove favorite failed: {e}") + session.modified = True + return jsonify({'ok': True, 'favorites': session['favorites']}) + +@app.route('/favorites/toggle/', methods=['POST']) +def toggle_fav(item_id): + _ensure_session_favs() + + session_favs = [str(f) for f in session.get('favorites', [])] + item_id = str(item_id) + is_favorite = item_id in session_favs + + username = session.get('username') + + if is_favorite: + session_favs = [f for f in session_favs if f != item_id] + if username: + try: + us.remove_favorite(username, item_id) + except Exception as e: + app.logger.warning(f"Persist toggle(remove) favorite failed: {e}") + else: + session_favs.append(item_id) + if username: + try: + us.add_favorite(username, item_id) + except Exception as e: + app.logger.warning(f"Persist toggle(add) favorite failed: {e}") + + # Normalize and de-duplicate while preserving order. + deduped = [] + seen = set() + for fav in session_favs: + if fav not in seen: + seen.add(fav) + deduped.append(fav) + + session['favorites'] = deduped + session.modified = True + + return jsonify({ + 'ok': True, + 'is_favorite': item_id in deduped, + 'favorites': deduped + }) + +@app.route('/debug/favorites') +def debug_favorites(): + """Diagnostic endpoint: shows session favorites, DB favorites and merged output.""" + username = session.get('username') + session_favs = list(session.get('favorites', [])) + db_favs = [] + if username: + try: + db_favs = us.get_favorites(username) + except Exception as e: + return jsonify({'ok': False, 'error': f'db_error: {e}', 'session': session_favs}) + merged = sorted(set(session_favs) | set(db_favs)) + return jsonify({'ok': True, 'user': username, 'session': session_favs, 'db': db_favs, 'merged': merged}) + + +@app.route('/upload_item', methods=['POST']) +def upload_item(): + """ + Route for adding new items to the inventory. + Handles file uploads and creates QR codes. + Enhanced for mobile browser compatibility. + + Returns: + flask.Response: Redirect to admin homepage or JSON response + """ + # Check if the user is authenticated + if 'username' not in session: + return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 + + # Check if user is an admin + username = session['username'] + if not us.check_admin(username): + return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403 + + # Detect if request is from mobile device + is_mobile = 'Mobile' in request.headers.get('User-Agent', '') + is_ios = 'iPhone' in request.headers.get('User-Agent', '') or 'iPad' in request.headers.get('User-Agent', '') + + # Log mobile request for debugging + if is_mobile: + app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {username}") + + try: + # Strip whitespace from all text fields + name = sanitize_form_value(request.form['name']) + ort = sanitize_form_value(request.form['ort']) + beschreibung = sanitize_form_value(request.form['beschreibung']) + + # Check both possible image field names + images = request.files.getlist('images') or request.files.getlist('new_images') + + filter_upload = sanitize_form_value(request.form.getlist('filter')) + filter_upload2 = sanitize_form_value(request.form.getlist('filter2')) + filter_upload3 = sanitize_form_value(request.form.getlist('filter3')) + anschaffungs_jahr = sanitize_form_value(request.form.getlist('anschaffungsjahr')) + anschaffungs_kosten = sanitize_form_value(request.form.getlist('anschaffungskosten')) + code_4 = sanitize_form_value(request.form.getlist('code_4')) + isbn_raw = sanitize_form_value(request.form.get('isbn', '')) + 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')) + + try: + item_count = int(item_count_raw) if item_count_raw else 1 + except (TypeError, ValueError): + item_count = 1 + item_count = max(1, min(item_count, 100)) + + # Optional list of per-item codes (one code per line) + individual_codes = [] + if individual_codes_raw: + individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if c.strip()] + + # Check if this is a duplication + is_duplicating = request.form.get('is_duplicating') == 'true' + + # Get duplicate_images if duplicating + duplicate_images = request.form.getlist('duplicate_images') if is_duplicating else [] + print(f"DEBUG: Duplicate images from form: {duplicate_images}, count: {len(duplicate_images)}") + + # Make sure duplicate_images is always a list, even if there's only one + if is_duplicating and duplicate_images and not isinstance(duplicate_images, list): + duplicate_images = [duplicate_images] + + # Log details about each image + if is_duplicating and duplicate_images: + for i, img in enumerate(duplicate_images): + print(f"DEBUG: Duplicate image {i+1}/{len(duplicate_images)}: {img}") + + # Get book cover image if downloaded + book_cover_image = request.form.get('book_cover_image') + + # Special handling for mobile browsers that might send data differently + if is_mobile and 'mobile_data' in request.form: + try: + mobile_data = json.loads(request.form['mobile_data']) + # Override values with mobile data if available + if 'filters' in mobile_data: + filter_upload = mobile_data.get('filters', []) + if 'filters2' in mobile_data: + filter_upload2 = mobile_data.get('filters2', []) + if 'filters3' in mobile_data: + filter_upload3 = mobile_data.get('filters3', []) + if 'duplicate_images' in mobile_data and mobile_data['duplicate_images']: + duplicate_images = mobile_data.get('duplicate_images', []) + except json.JSONDecodeError as e: + app.logger.error(f"Error parsing mobile data: {str(e)}") + except Exception as e: + error_msg = f"Fehler beim Verarbeiten der Formulardaten: {str(e)}" + app.logger.error(error_msg) + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + else: + flash('Fehler beim Verarbeiten der Formulardaten. Bitte versuchen Sie es erneut.', 'error') + return redirect(url_for('home_admin')) + + # Validation + if not name or not ort or not beschreibung: + error_msg = 'Bitte füllen Sie alle erforderlichen Felder aus' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + else: + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + item_isbn = '' + item_type = 'general' + if cfg.LIBRARY_MODULE_ENABLED: + item_isbn = normalize_and_validate_isbn(isbn_raw) + if isbn_raw and not item_isbn: + error_msg = 'Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + if item_isbn: + item_type = 'book' + + if upload_mode == 'library': + if not cfg.LIBRARY_MODULE_ENABLED: + error_msg = 'Bibliotheks-Modul ist deaktiviert.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + if not item_isbn: + error_msg = 'Für Bücher ist eine gültige ISBN erforderlich.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + flash(error_msg, 'error') + return redirect(url_for('library_admin')) + item_type = 'book' + + # Only check for images if not duplicating and no duplicate images provided and no book cover + # For library mode, skip this check as images come only from ISBN fetch + if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image: + error_msg = 'Bitte laden Sie mindestens ein Bild hoch' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + else: + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + # Check if base code is unique for single-item uploads + if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]): + error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + else: + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + # Validate optional per-item codes + if individual_codes: + if len(individual_codes) > item_count: + error_msg = f'Zu viele Einzelcodes angegeben ({len(individual_codes)}), erlaubt sind maximal {item_count}.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + if len(set(individual_codes)) != len(individual_codes): + error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + for specific_code in individual_codes: + if not it.is_code_unique(specific_code): + error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + def generate_unique_batch_code(base_code, position): + """Generate a unique code for every item in a batch.""" + if not base_code: + return None + + candidate = base_code if position == 1 else f"{base_code}-{position}" + if it.is_code_unique(candidate): + return candidate + + suffix = 1 + while suffix <= 1000: + alternative = f"{candidate}-{suffix}" + if it.is_code_unique(alternative): + return alternative + suffix += 1 + return None + + # Process any new uploaded images with robust error handling + image_filenames = [] + processed_count = 0 + error_count = 0 + skipped_count = 0 + + # Create a structured log entry for upload session + upload_session_id = str(uuid.uuid4())[:8] + app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {username}") + + # Ensure all required directories exist + for directory in [app.config['UPLOAD_FOLDER']]: + try: + os.makedirs(directory, exist_ok=True) + except Exception as e: + app.logger.error(f"Failed to create directory {directory}: {str(e)}") + + # Process each image independently + for index, image in enumerate(images): + # In library mode, skip manual image uploads (use only book_cover_image from ISBN fetch) + if upload_mode == 'library': + app.logger.info(f"[Library Mode] Skipping manual image upload {index+1}/{len(images)}") + skipped_count += 1 + continue + + image_log_prefix = f"[Upload {upload_session_id}][Image {index+1}/{len(images)}]" + + if not image or not image.filename or image.filename == '': + app.logger.warning(f"{image_log_prefix} Empty file or filename") + skipped_count += 1 + continue + + # Get file extension for special handling + _, file_ext = os.path.splitext(image.filename.lower()) + is_png = file_ext.lower() == '.png' + + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Detected PNG file: {image.filename}") + # Check file size + image.seek(0, os.SEEK_END) + file_size = image.tell() / (1024 * 1024) # Size in MB + image.seek(0) # Reset file pointer + app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG file size: {file_size:.2f}MB") + + # Check first few bytes for PNG signature and analyze header + header_bytes = image.read(64) # Read more for thorough analysis + image.seek(0) # Reset pointer + png_signature = b'\x89PNG\r\n\x1a\n' + is_valid_signature = header_bytes.startswith(png_signature) + + # Create a hex dump of header for debugging + hex_dump = ' '.join([f"{b:02x}" for b in header_bytes[:32]]) + app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG header hex: {hex_dump}") + app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG signature valid: {is_valid_signature}, bytes: {header_bytes[:8]!r}") + + # Analyze PNG chunks if signature is valid + if is_valid_signature: + try: + # Look for IHDR chunk that should follow the signature + if header_bytes[8:12] == b'IHDR': + # Extract width and height from IHDR chunk (bytes 16-23) + import struct + width = struct.unpack('>I', header_bytes[16:20])[0] + height = struct.unpack('>I', header_bytes[20:24])[0] + bit_depth = header_bytes[24] + color_type = header_bytes[25] + app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG dimensions from header: {width}x{height}, bit depth: {bit_depth}, color type: {color_type}") + else: + app.logger.warning(f"PNG DEBUG: {image_log_prefix} Expected IHDR chunk not found. Found: {header_bytes[8:12]!r}") + except Exception as chunk_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error analyzing PNG chunks: {str(chunk_err)}") + else: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Invalid PNG signature!") + + app.logger.info(f"{image_log_prefix} Processing: {image.filename}") + + try: + # Comprehensive file validation with detailed logging + is_allowed, error_message = allowed_file(image.filename, image, max_size_mb=cfg.IMAGE_MAX_UPLOAD_MB) + + if not is_allowed: + app.logger.warning(f"{image_log_prefix} Validation failed: {error_message}") + if is_png: + app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG validation failed: {error_message}") + skipped_count += 1 + if not is_mobile: + flash(error_message, 'error') + continue + + # Get the file extension for content type determination + secure_name = secure_filename(image.filename) + _, ext_part = os.path.splitext(secure_name) + is_png = ext_part.lower() == '.png' + + # Generate a completely unique filename using UUID + unique_id = str(uuid.uuid4()) + timestamp = time.strftime("%Y%m%d%H%M%S") + + # New filename format with UUID to ensure uniqueness + saved_filename = f"{unique_id}_{timestamp}{ext_part}" + app.logger.info(f"{image_log_prefix} Assigned unique filename: {saved_filename}") + + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Creating PNG with filename: {saved_filename}") + + # For iOS devices, we need special handling for the file save + if is_ios: + app.logger.info(f"{image_log_prefix} Using iOS-specific file handling") + # Save to a temporary file first to avoid iOS stream issues + temp_path = os.path.join(app.config['UPLOAD_FOLDER'], f"temp_{saved_filename}") + + try: + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Using iOS PNG save method") + # Before saving, verify the file content again + try: + image.seek(0) + pre_save_data = image.read(16) + image.seek(0) + pre_save_hex = ' '.join([f"{b:02x}" for b in pre_save_data]) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Pre-save data: {pre_save_hex}") + except Exception as pre_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error checking pre-save data: {str(pre_err)}") + + # For PNGs, try a direct binary save first + if is_png: + try: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting binary save for iOS PNG") + image.seek(0) + png_data = image.read() + with open(temp_path, 'wb') as f: + f.write(png_data) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Binary write complete, size: {len(png_data)} bytes") + except Exception as bin_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Binary write failed: {str(bin_err)}") + # Fall back to normal save + image.seek(0) + image.save(temp_path) + else: + image.save(temp_path) + + # Validate the saved file + if os.path.exists(temp_path) and os.path.getsize(temp_path) > 0: + if is_png: + file_size = os.path.getsize(temp_path) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Temp PNG file saved successfully: {file_size/1024:.1f}KB") + + # Verify it's a valid PNG + try: + with open(temp_path, 'rb') as f: + png_header = f.read(16) + png_signature = b'\x89PNG\r\n\x1a\n' + is_valid = png_header.startswith(png_signature) + + header_hex = ' '.join([f"{b:02x}" for b in png_header]) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved file header: {header_hex}") + + if not is_valid: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Invalid PNG signature in saved file!") + else: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Valid PNG signature confirmed in saved file") + + # Try opening with PIL to confirm it's valid + try: + with Image.open(temp_path) as img: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG validates with PIL: {img.format} {img.size}") + except Exception as pil_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Saved PNG fails PIL validation: {str(pil_err)}") + + except Exception as verify_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error verifying PNG: {str(verify_err)}") + + # Rename to the final filename + final_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) + os.rename(temp_path, final_path) + app.logger.info(f"{image_log_prefix} Successfully saved via iOS handler: {os.path.getsize(final_path)/1024:.1f}KB") + else: + if is_png: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Failed to save temp PNG file (zero size or missing)") + raise Exception("Failed to save image file (zero size or missing)") + except Exception as e: + app.logger.error(f"{image_log_prefix} iOS save failed: {str(e)}") + if is_png: + app.logger.error(f"PNG DEBUG: {image_log_prefix} iOS PNG save failed: {str(e)}") + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(e).__name__}") + # Log full traceback for PNG errors + import io + tb_output = io.StringIO() + traceback.print_exc(file=tb_output) + app.logger.error(f"PNG DEBUG: {image_log_prefix} Full traceback:\n{tb_output.getvalue()}") + + # Try regular save as fallback + try: + image.seek(0) # Reset file pointer + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting fallback PNG save method") + + image.save(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename)) + app.logger.info(f"{image_log_prefix} Fallback save successful") + + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Fallback PNG save successful") + except Exception as fallback_err: + app.logger.error(f"{image_log_prefix} Fallback save also failed: {str(fallback_err)}") + if is_png: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Fallback PNG save also failed: {str(fallback_err)}") + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(fallback_err).__name__}") + traceback.print_exc() + error_count += 1 + continue + else: + # Regular file save for non-iOS devices + try: + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Using standard PNG save method") + + # Check file content before saving + try: + image.seek(0) + pre_save_data = image.read(16) + image.seek(0) + pre_save_hex = ' '.join([f"{b:02x}" for b in pre_save_data]) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Pre-save data: {pre_save_hex}") + + # Check if it's really a PNG + png_signature = b'\x89PNG\r\n\x1a\n' + if not pre_save_data.startswith(png_signature): + app.logger.error(f"PNG DEBUG: {image_log_prefix} File does not have valid PNG signature before save!") + except Exception as pre_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error checking pre-save data: {str(pre_err)}") + + # Try an alternative saving method for PNGs with detailed error tracking + try: + # Read the image data directly + image.seek(0) + image_data = image.read() + app.logger.info(f"PNG DEBUG: {image_log_prefix} Read {len(image_data)} bytes of PNG data") + + # Write it manually to file + save_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) + with open(save_path, 'wb') as f: + f.write(image_data) + + file_size = os.path.getsize(save_path) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Direct binary PNG write successful: {file_size/1024:.1f}KB") + + # Verify the saved file + try: + with open(save_path, 'rb') as f: + saved_header = f.read(16) + header_hex = ' '.join([f"{b:02x}" for b in saved_header]) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG header: {header_hex}") + + is_valid = saved_header.startswith(png_signature) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG has valid signature: {is_valid}") + + if is_valid: + # Additional validation with PIL + try: + with Image.open(save_path) as img: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG validates with PIL: {img.format} {img.size}") + except Exception as pil_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Saved PNG fails PIL validation: {str(pil_err)}") + except Exception as verify_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error verifying saved PNG: {str(verify_err)}") + + except Exception as png_write_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Direct PNG write failed: {str(png_write_err)}") + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(png_write_err).__name__}") + + # Log traceback for PNG errors + import io + tb_output = io.StringIO() + traceback.print_exc(file=tb_output) + app.logger.error(f"PNG DEBUG: {image_log_prefix} Binary write traceback:\n{tb_output.getvalue()}") + + # Fall back to standard method + app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting standard PIL save method") + image.seek(0) + image.save(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename)) + else: + # Standard save for non-PNG files + image.save(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename)) + + app.logger.info(f"{image_log_prefix} Standard save successful") + except Exception as save_err: + app.logger.error(f"{image_log_prefix} Failed to save file: {str(save_err)}") + if is_png: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Standard PNG save failed: {str(save_err)}") + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(save_err).__name__}") + + # Log traceback for PNG errors + import io + tb_output = io.StringIO() + traceback.print_exc(file=tb_output) + app.logger.error(f"PNG DEBUG: {image_log_prefix} Standard save traceback:\n{tb_output.getvalue()}") + + # Try one last method - save as a different format + try: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting last resort conversion to WebP") + image.seek(0) + + # Try to convert PNG to WebP as a last resort + with Image.open(image) as img: + # Ensure RGBA for transparency + if img.mode != 'RGBA': + img = img.convert('RGBA') + webp_path = os.path.splitext(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename))[0] + '.webp' + img.save(webp_path, 'WEBP') + app.logger.info(f"PNG DEBUG: {image_log_prefix} Successfully saved as WebP instead: {webp_path}") + # Update the saved_filename to reflect the new extension + saved_filename = os.path.basename(webp_path) + except Exception as webp_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Final WebP conversion failed: {str(webp_err)}") + + traceback.print_exc() + error_count += 1 + continue + + # Verify the file was saved correctly + saved_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) + if not os.path.exists(saved_path) or os.path.getsize(saved_path) == 0: + app.logger.error(f"{image_log_prefix} Saved file is missing or empty: {saved_path}") + if is_png: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Saved PNG is missing or empty: {saved_path}") + error_count += 1 + continue + + # Special verification for PNG files + if is_png: + try: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Verifying saved PNG: {saved_path}") + saved_size = os.path.getsize(saved_path) / 1024.0 # in KB + app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG size: {saved_size:.1f}KB") + + # Check file integrity by trying to open it + try: + with Image.open(saved_path) as img: + png_width, png_height = img.size + png_mode = img.mode + app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG valid: {png_width}x{png_height}, mode: {png_mode}") + except Exception as png_verify_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG verification failed: {str(png_verify_err)}") + + # Try to fix by copying from the original + try: + image.seek(0) + with open(saved_path, 'wb') as f: + f.write(image.read()) + app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempted to fix PNG by direct copy") + except Exception as png_fix_err: + app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG fix attempt failed: {str(png_fix_err)}") + except Exception as e: + app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG verification error: {str(e)}") + + # Generate optimized versions (thumbnails and previews) + optimization_success = False + try: + # Log original file size before optimization + original_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) + original_size = os.path.getsize(original_path) + + # Get original image dimensions + original_dimensions = "unknown" + try: + with Image.open(original_path) as img: + original_dimensions = f"{img.width}x{img.height}" + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Original PNG dimensions: {original_dimensions}, mode: {img.mode}") + except Exception as dim_err: + app.logger.warning(f"{image_log_prefix} Could not get image dimensions: {str(dim_err)}") + if is_png: + app.logger.error(f"PNG DEBUG: {image_log_prefix} Could not get PNG dimensions: {str(dim_err)}") + app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(dim_err).__name__}") + traceback.print_exc() + + app.logger.info(f"{image_log_prefix} Starting optimization for {saved_filename} ({original_size/1024:.1f}KB, {original_dimensions})") + + # PNG-specific optimization options + if is_png: + app.logger.info(f"PNG DEBUG: {image_log_prefix} Starting PNG optimization") + # For PNGs, we might need different parameters + optimization_result = generate_optimized_versions( + saved_filename, + max_original_width=500, + target_size_kb=100, # Higher target for PNGs to maintain transparency + debug_prefix=f"PNG DEBUG: {image_log_prefix}" + ) + else: + # Standard optimization for non-PNG images + optimization_result = generate_optimized_versions(saved_filename, max_original_width=500, target_size_kb=80) + + # Log file size after optimization + if optimization_result['success'] and optimization_result['original']: + optimized_name = optimization_result['original'] + optimized_path = os.path.join(app.config['UPLOAD_FOLDER'], optimized_name) + + if os.path.exists(optimized_path): + optimized_size = os.path.getsize(optimized_path) + reduction = (1 - (optimized_size / original_size)) * 100 if original_size > 0 else 0 + + # Get optimized dimensions + optimized_dimensions = "unknown" + try: + with Image.open(optimized_path) as img: + optimized_dimensions = f"{img.width}x{img.height}" + except Exception as dim_err: + app.logger.warning(f"{image_log_prefix} Could not get optimized dimensions: {str(dim_err)}") + + app.logger.info( + f"{image_log_prefix} Optimization results:\n" + f" File: {saved_filename} → {optimized_name}\n" + f" Size: {original_size/1024:.1f}KB → {optimized_size/1024:.1f}KB ({reduction:.1f}% reduction)\n" + f" Dimensions: {original_dimensions} → {optimized_dimensions}" + ) + + # Use the optimized filename + saved_filename = optimized_name + else: + app.logger.warning(f"{image_log_prefix} Optimized file reported success but not found: {optimized_path}") + else: + app.logger.warning(f"{image_log_prefix} Optimization failed or returned no file") + except Exception as e: + app.logger.error(f"{image_log_prefix} Optimization failed: {str(e)}") + traceback.print_exc() + + # No fallback thumbnail generation needed as we only use the main image + + # Always add the filename to our list even if optimization failed + # We'll use the original in that case + image_filenames.append(saved_filename) + processed_count += 1 + app.logger.info(f"{image_log_prefix} Successfully processed") + + except Exception as e: + app.logger.error(f"{image_log_prefix} Unexpected error: {str(e)}") + traceback.print_exc() + error_count += 1 + # Continue with the next image + + # Log summary of upload session + app.logger.info(f"Upload session {upload_session_id} completed: {processed_count} processed, {error_count} errors, {skipped_count} skipped") + + # Handle duplicate images if duplicating + if duplicate_images: + app.logger.info(f"Processing {len(duplicate_images)} duplicate images: {duplicate_images}") + + # For mobile browsers, we need to verify the duplicate images exist first + verified_duplicates = [] + for dup_img in duplicate_images: + # Try looking in different paths + # Add all possible paths where images might be stored + dev_upload_path = app.config['UPLOAD_FOLDER'] + prod_upload_path = '/var/Inventarsystem/Web/uploads' + + # Also look for image variations with suffixes that might be in the path + name_part, ext_part = os.path.splitext(dup_img) + possible_filenames = [ + dup_img, + f"{name_part}.webp", # Check WebP first + f"{name_part}.jpg", # In case it was converted to JPG + f"{name_part}.png", # In case it was saved as PNG + ] + + possible_paths = [] + for filename in possible_filenames: + possible_paths.extend([ + os.path.join(dev_upload_path, filename), # Development upload path + os.path.join(prod_upload_path, filename), # Production upload path + ]) + + app.logger.info(f"Looking for duplicate image {dup_img} in paths: {possible_paths}") + + # Try to find the original image + found = False + for path in possible_paths: + if os.path.exists(path) and os.path.isfile(path): + verified_duplicates.append((dup_img, path)) + app.logger.info(f"Found duplicate image at: {path}") + found = True + break + + if not found: + app.logger.warning(f"Duplicate image not found: {dup_img}") + # Try to find any image with a similar filename (removing size or resolution parts) + # This handles cases where the filename may have variations like "_800" suffix + base_name = os.path.splitext(dup_img)[0] + base_name = re.sub(r'_\d+$', '', base_name) # Remove trailing _NUMBER + + if len(base_name) > 5: # Only if we have a meaningful base name + app.logger.info(f"Trying to find similar images with base name: {base_name}") + + # Search in development directory + dev_files = os.listdir(app.config['UPLOAD_FOLDER']) if os.path.exists(app.config['UPLOAD_FOLDER']) else [] + # Search in production directory + prod_path = "/var/Inventarsystem/Web/uploads" + prod_files = os.listdir(prod_path) if os.path.exists(prod_path) else [] + + # Combine all files + all_files = dev_files + prod_files + + # Find similar files + for f in all_files: + if base_name in f: + img_path = os.path.join(app.config['UPLOAD_FOLDER'], f) + if not os.path.exists(img_path): + img_path = os.path.join(prod_path, f) + + if os.path.exists(img_path) and os.path.isfile(img_path): + app.logger.info(f"Found similar image: {f} at {img_path}") + verified_duplicates.append((f, img_path)) + found = True + break + + # If we still can't find anything, just use a placeholder + if not found: + app.logger.warning(f"Could not find any similar image for: {dup_img}, will use placeholder") + + # Create copies of verified images with new unique filenames + duplicate_image_copies = [] + + # Create a placeholder name for each image that wasn't found + placeholder_used = False + original_count = len(duplicate_images) + + # Process each original image - either use found file or placeholder + for i, dup_img in enumerate(duplicate_images): + # Look for corresponding verified image + found_image = None + for verified_img, src_path in verified_duplicates: + if verified_img == dup_img: + found_image = (verified_img, src_path) + break + + try: + # Generate a new unique filename (same for real or placeholder) + unique_id = str(uuid.uuid4()) + timestamp = time.strftime("%Y%m%d%H%M%S") + _, ext_part = os.path.splitext(dup_img) if dup_img else '.jpg' + new_filename = f"{unique_id}_{timestamp}{ext_part}" + + # If we found the image, copy it + if found_image: + dup_img, src_path = found_image + app.logger.info(f"Copying image {i+1}/{original_count} from {src_path} to {new_filename}") + + # Copy the image file to the new name + dst_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) + + # Make sure the target directory exists + os.makedirs(os.path.dirname(dst_path), exist_ok=True) + + # Copy the file + shutil.copy2(src_path, dst_path) + + # Verify the file was copied successfully + if os.path.exists(dst_path): + app.logger.info(f"Successfully copied image to {dst_path}") + else: + app.logger.error(f"Failed to copy image to {dst_path}") + # If copy fails, use placeholder + raise Exception("Copy failed - will use placeholder") + + # Generate optimized versions (thumbnails and previews) for the new copy + try: + result = generate_optimized_versions(new_filename, max_original_width=500, target_size_kb=80) + app.logger.info(f"Generated optimized versions: {result}") + if result['success'] and result['original']: + new_filename = result['original'] + except Exception as e: + app.logger.error(f"Error generating optimized versions for {new_filename}: {e}") + # If optimization fails, at least keep the original file + result = {'original': new_filename} + traceback.print_exc() + + # If we didn't find the image, use a placeholder + else: + app.logger.warning(f"Using placeholder for image {i+1}/{original_count} (original: {dup_img})") + + # Copy placeholder to uploads directory with the new filename + placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') + if not os.path.exists(placeholder_path): + placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') + + if os.path.exists(placeholder_path): + dst_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) + shutil.copy2(placeholder_path, dst_path) + app.logger.info(f"Copied placeholder image to {dst_path}") + placeholder_used = True + + # Skip the optimization step for placeholder images + # Just add directly to the list of image filenames + continue + else: + app.logger.error(f"Placeholder image not found at {placeholder_path}") + # Create a simple placeholder file + with open(os.path.join(app.config['UPLOAD_FOLDER'], new_filename), 'w') as f: + f.write("Placeholder") + placeholder_used = True + # Skip the optimization step + continue + + # Add the new filename to our list (either copied or placeholder) + duplicate_image_copies.append(new_filename) + processed_count += 1 + + app.logger.info(f"Processed image {i+1}/{original_count}: {new_filename}") + except Exception as e: + app.logger.error(f"Error processing image {i+1}/{original_count} ({dup_img}): {str(e)}") + traceback.print_exc() + error_count += 1 + + # Log placeholder usage + if placeholder_used: + app.logger.warning(f"Used placeholders for some missing images during duplication") + + # Log if no images were processed + if not duplicate_image_copies: + app.logger.warning(f"No duplicate images were processed") + if duplicate_images: + app.logger.warning(f"Original had {len(duplicate_images)} images, but none were copied") + + # Add the new image copies to our list of filenames + image_filenames.extend(duplicate_image_copies) + + # Handle book cover image if provided + if book_cover_image: + # Verify the book cover image exists + full_path = os.path.join(app.config['UPLOAD_FOLDER'], book_cover_image) + if os.path.exists(full_path) and os.path.isfile(full_path): + # Create a unique filename for the book cover + unique_id = str(uuid.uuid4()) + timestamp = time.strftime("%Y%m%d%H%M%S") + _, ext_part = os.path.splitext(book_cover_image) + + new_filename = f"{unique_id}_{timestamp}_book_cover{ext_part}" + new_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) + + # Copy the file to the new unique name + shutil.copy2(full_path, new_path) + + # Use the new filename instead + image_filenames.append(new_filename) + app.logger.info(f"Copied book cover from {book_cover_image} to {new_filename}") + else: + app.logger.warning(f"Book cover image not found: {book_cover_image}") + + # Log image processing stats + app.logger.info(f"Upload stats: processed={processed_count}, errors={error_count}, skipped={skipped_count}, duplicates={len(duplicate_images) if duplicate_images else 0}") + + # If location is not in the predefined list, add it + predefined_locations = it.get_predefined_locations() + if ort and ort not in predefined_locations: + it.add_predefined_location(ort) + + reservierbar = 'reservierbar' in request.form + + # Add one or more own items; sub-items are hidden in overview and counted on parent. + created_item_ids = [] + series_group_id = str(uuid.uuid4()) if item_count > 1 else None + base_code = code_4[0] if code_4 else None + + for position in range(1, item_count + 1): + unique_code = None + if position <= len(individual_codes): + unique_code = individual_codes[position - 1] + else: + unique_code = generate_unique_batch_code(base_code, position) + + if (base_code or individual_codes) and not unique_code: + error_msg = 'Fehler bei der Code-Erzeugung für mehrere Artikel.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 400 + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + parent_item_id = str(created_item_ids[0]) if created_item_ids else None + item_id = it.add_item( + name, ort, beschreibung, image_filenames, filter_upload, + filter_upload2, filter_upload3, + anschaffungs_jahr[0] if anschaffungs_jahr else None, + anschaffungs_kosten[0] if anschaffungs_kosten else None, + unique_code, + reservierbar=reservierbar, + series_group_id=series_group_id, + series_count=item_count, + series_position=position, + is_grouped_sub_item=(position > 1), + parent_item_id=parent_item_id, + isbn=item_isbn, + item_type=item_type + ) + if not item_id: + break + created_item_ids.append(item_id) + + if len(created_item_ids) != item_count: + error_msg = f'Nur {len(created_item_ids)} von {item_count} Artikeln konnten erstellt werden.' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 500 + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + item_id = created_item_ids[0] if created_item_ids else None + + if item_id: + # Create QR code for the item (deactivated) + # create_qr_code(str(item_id)) + success_msg = f'Element wurde erfolgreich hinzugefügt ({len(created_item_ids)} erstellt)' + + 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('home_admin', highlight_item=str(item_id))) + else: + error_msg = 'Fehler beim Hinzufügen des Elements' + if is_mobile: + return jsonify({'success': False, 'message': error_msg}), 500 + else: + flash(error_msg, 'error') + return redirect(url_for('home_admin')) + + +@app.route('/duplicate_item', methods=['POST']) +def duplicate_item(): + """ + Route for duplicating an existing item. + Returns JSON response with success status. + Enhanced for mobile browser compatibility. + + Returns: + flask.Response: JSON response with success status and data + """ + try: + # Check authentication + if 'username' not in session: + return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 + + # Check if user is admin + username = session['username'] + if not us.check_admin(username): + return jsonify({'success': False, 'message': 'Keine Administratorrechte'}), 403 + + # Detect if request is from mobile device + is_mobile = 'Mobile' in request.headers.get('User-Agent', '') + is_ios = 'iPhone' in request.headers.get('User-Agent', '') or 'iPad' in request.headers.get('User-Agent', '') + + # Log mobile duplication for debugging + if is_mobile: + app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {username}") + + # Get original item ID + original_item_id = request.form.get('original_item_id') + if not original_item_id: + return jsonify({'success': False, 'message': 'Ursprungs-Element-ID fehlt'}), 400 + + # Fetch original item data + original_item = it.get_item(original_item_id) + if not original_item: + return jsonify({'success': False, 'message': 'Ursprungs-Element nicht gefunden'}), 404 + + # Process filters as arrays (same as stored in database) + filter1_array = original_item.get('Filter', []) + filter2_array = original_item.get('Filter2', []) + filter3_array = original_item.get('Filter3', []) + + # Ensure filters are arrays + if not isinstance(filter1_array, list): + filter1_array = [filter1_array] if filter1_array else [] + if not isinstance(filter2_array, list): + filter2_array = [filter2_array] if filter2_array else [] + if not isinstance(filter3_array, list): + filter3_array = [filter3_array] if filter3_array else [] + + # Verify image paths for mobile devices to avoid issues with non-existent images + images = original_item.get('Images', []) + verified_images = [] + + if is_mobile: + for img in images: + img_path = os.path.join(app.config['UPLOAD_FOLDER'], img) + if os.path.exists(img_path) and os.path.isfile(img_path): + verified_images.append(img) + else: + app.logger.warning(f"Image not found for duplication: {img}") + + # If we lost images in verification, log it + if len(verified_images) < len(images): + app.logger.warning(f"Only {len(verified_images)} of {len(images)} images verified for mobile duplication") + else: + verified_images = images + + # For iOS devices, add more diagnostics and reduce data size if needed + if is_ios: + # Check if images exist (we now use main images as thumbnails) + images_exist = [] + for img in verified_images[:5]: # Only check first 5 to save time + img_path = os.path.join(app.config['UPLOAD_FOLDER'], img) + if os.path.exists(img_path): + images_exist.append(True) + else: + images_exist.append(False) + + # Log detailed diagnostics + app.logger.info(f"iOS duplication details: {len(verified_images)} images, " + f"images available: {all(images_exist)}, " + f"filter sizes: {len(filter1_array)}, {len(filter2_array)}, {len(filter3_array)}") + + return jsonify({ + 'success': True, + 'message': 'Duplizierungsdaten erfolgreich vorbereitet', + 'item_data': { + 'name': original_item.get('Name', ''), + 'description': original_item.get('Beschreibung', ''), + 'location': original_item.get('Ort', ''), + 'room': original_item.get('Raum', ''), + 'category': original_item.get('Kategorie', ''), + 'year': original_item.get('Anschaffungsjahr', ''), + 'cost': original_item.get('Anschaffungskosten', ''), + 'filter1': filter1_array, + 'filter2': filter2_array, + 'filter3': filter3_array, + 'images': verified_images, # Using verified images instead of original + 'isMobile': is_mobile, + 'isIOS': is_ios + } + }) + + except Exception as e: + print(f"Error in duplicate_item: {e}") + traceback.print_exc() + return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500 + + +@app.route('/delete_item/', methods=['POST', 'GET']) +def delete_item(id): + """ + Route for deleting inventory items. + + Args: + id (str): ID of the item to delete + + Returns: + flask.Response: Redirect to admin homepage + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Resolve all related item ids (grouped variants) and load their data + group_item_ids = it.get_group_item_ids(id) + if not group_item_ids: + flash('Element nicht gefunden.', 'error') + return redirect(url_for('home_admin')) + + group_items = [] + for group_item_id in group_item_ids: + group_item = it.get_item(group_item_id) + if group_item: + group_items.append(group_item) + + if not group_items: + flash('Element nicht gefunden.', 'error') + return redirect(url_for('home_admin')) + + # Collect all referenced images once to avoid duplicate deletion attempts + image_filenames = [] + seen_images = set() + for group_item in group_items: + for filename in group_item.get('Images', []): + if filename and filename not in seen_images: + seen_images.add(filename) + image_filenames.append(filename) + + # Attempt to delete image files + stats = {'originals': 0, 'thumbnails': 0, 'previews': 0, 'errors': 0} + try: + stats = delete_item_images(image_filenames) + app.logger.info(f"Item group deletion ({len(group_item_ids)} IDs) - Images removed: " + + f"originals={stats['originals']}, thumbnails={stats['thumbnails']}, " + + f"previews={stats['previews']}, errors={stats['errors']}") + except Exception as e: + app.logger.error(f"Error deleting images for item group {id}: {str(e)}") + + # Delete all items in the group and related borrow entries + delete_success = True + for group_item_id in group_item_ids: + if not it.remove_item(group_item_id): + delete_success = False + + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + db['ausleihungen'].delete_many({'Item': {'$in': group_item_ids}}) + client.close() + except Exception as e: + app.logger.error(f"Error deleting borrowing records for item group {id}: {str(e)}") + + if delete_success: + flash(f'Elementgruppe erfolgreich gelöscht ({len(group_item_ids)} Versionen). {stats["originals"]} Bilder entfernt.', 'success') + else: + flash('Fehler beim Löschen des Elements aus der Datenbank.', 'error') + + return redirect(url_for('home_admin')) + + +@app.route('/edit_item/', methods=['POST']) +def edit_item(id): + """ + Route for editing an existing inventory item. + + Args: + id (str): ID of the item to edit + + Returns: + flask.Response: Redirect to admin homepage with status message + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Strip whitespace from all text fields + name = sanitize_form_value(request.form.get('name')) + ort = sanitize_form_value(request.form.get('ort')) + beschreibung = sanitize_form_value(request.form.get('beschreibung')) + + # Strip whitespace from all filter values + filter1 = sanitize_form_value(request.form.getlist('filter')) + filter2 = sanitize_form_value(request.form.getlist('filter2')) + filter3 = sanitize_form_value(request.form.getlist('filter3')) + + anschaffungs_jahr = sanitize_form_value(request.form.get('anschaffungsjahr')) + anschaffungs_kosten = sanitize_form_value(request.form.get('anschaffungskosten')) + code_4 = sanitize_form_value(request.form.get('code_4')) + isbn_raw = sanitize_form_value(request.form.get('isbn', '')) + reservierbar = 'reservierbar' in request.form + + item_isbn = '' + item_type = 'general' + if cfg.LIBRARY_MODULE_ENABLED: + item_isbn = normalize_and_validate_isbn(isbn_raw) + if isbn_raw and not item_isbn: + flash('Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden.', 'error') + return redirect(url_for('home_admin')) + if item_isbn: + item_type = 'book' + + # Check if code is unique (excluding the current item) + if code_4 and not it.is_code_unique(code_4, exclude_id=id): + flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error') + return redirect(url_for('home_admin')) + + # Get current item to check availability status + current_item = it.get_item(id) + if not current_item: + flash('Element nicht gefunden', 'error') + return redirect(url_for('home_admin')) + + # Preserve current availability status + verfuegbar = current_item.get('Verfuegbar', True) + + # Handle existing images - get list of images to keep + images_to_keep = request.form.getlist('existing_images') + + # Get the original list of images from the item + original_images = current_item.get('Images', []) + + # Keep only the images that weren't marked for deletion + images = [img for img in original_images if img in images_to_keep] + + # Handle new image uploads + new_images = request.files.getlist('new_images') + + # Process any new image uploads + for image in new_images: + if image and image.filename: + is_allowed, error_message = allowed_file(image.filename) + if is_allowed: + # Get the file extension + _, ext_part = os.path.splitext(secure_filename(image.filename)) + + # Generate a completely unique filename using UUID + unique_id = str(uuid.uuid4()) + timestamp = time.strftime("%Y%m%d%H%M%S") + + # New filename format with UUID to ensure uniqueness + filename = f"{unique_id}_{timestamp}{ext_part}" + + image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) + + # Optimize the image + try: + opt_result = generate_optimized_versions(filename, max_original_width=500, target_size_kb=80) + if opt_result['success'] and opt_result['original']: + filename = opt_result['original'] + except Exception as e: + app.logger.error(f"Error optimizing image in edit_item: {e}") + + images.append(filename) + else: + flash(error_message, 'error') + return redirect(url_for('home_admin')) + + # If location is not in the predefined list, maybe add it (depending on policy) + predefined_locations = it.get_predefined_locations() + if ort and ort not in predefined_locations: + it.add_predefined_location(ort) + + # Update the item + result = it.update_item( + id, name, ort, beschreibung, + images, verfuegbar, filter1, filter2, filter3, + anschaffungs_jahr, anschaffungs_kosten, code_4, reservierbar, + isbn=item_isbn, + item_type=item_type + ) + + if result: + flash('Element erfolgreich aktualisiert', 'success') + else: + flash('Fehler beim Aktualisieren des Elements', 'error') + + return redirect(url_for('home_admin')) + + +@app.route('/report_damage/', methods=['POST']) +def report_damage(id): + """Register a damage report entry for an item (admin only).""" + if 'username' not in session: + return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 + if not us.check_admin(session['username']): + return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403 + + payload = request.get_json(silent=True) or {} + description = str(payload.get('description', '')).strip() or 'Schaden gemeldet' + + if len(description) > 1000: + return jsonify({'success': False, 'message': 'Die Schadensbeschreibung ist zu lang (max. 1000 Zeichen).'}), 400 + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + item_doc = items_col.find_one({'_id': ObjectId(id)}, {'Name': 1}) + if not item_doc: + return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 + + now = datetime.datetime.now() + damage_entry = { + 'description': description, + 'reported_by': session['username'], + 'reported_at': now, + } + + result = items_col.update_one( + {'_id': ObjectId(id)}, + { + '$push': {'DamageReports': {'$each': [damage_entry], '$position': 0}}, + '$set': {'HasDamage': True, 'LastUpdated': now} + } + ) + + if result.matched_count == 0: + return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 + + updated_item = items_col.find_one({'_id': ObjectId(id)}, {'DamageReports': 1}) + damage_count = len(updated_item.get('DamageReports', [])) if updated_item else 0 + + # Best-effort system log entry for auditability + try: + logs_collection = db['system_logs'] + logs_collection.insert_one({ + 'type': 'damage_report', + 'timestamp': now.isoformat(), + 'user': session.get('username'), + 'item_id': id, + 'item_name': item_doc.get('Name', ''), + 'note': description, + 'damage_count': damage_count, + 'ip': request.remote_addr + }) + except Exception as log_err: + app.logger.warning(f"Damage report log write failed for item {id}: {log_err}") + + return jsonify({ + 'success': True, + 'message': 'Schaden erfolgreich erfasst.', + 'damage_count': damage_count + }) + except Exception as e: + app.logger.error(f"Error reporting damage for item {id}: {e}") + return jsonify({'success': False, 'message': 'Fehler beim Speichern der Schadensmeldung.'}), 500 + finally: + if client: + client.close() + + +@app.route('/mark_damage_repaired/', methods=['POST']) +def mark_damage_repaired(id): + """Mark all currently open damage reports of an item as repaired (admin only).""" + if 'username' not in session: + return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 + if not us.check_admin(session['username']): + return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403 + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + ausleihungen_col = db['ausleihungen'] + + item = items_col.find_one({'_id': ObjectId(id)}, {'DamageReports': 1, 'DamageRepairs': 1}) + if not item: + return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 + + open_reports = item.get('DamageReports', []) + if not open_reports: + return jsonify({'success': False, 'message': 'Keine offenen Schäden vorhanden.'}), 400 + + active_borrow = ausleihungen_col.find_one({'Item': str(id), 'Status': 'active'}, {'_id': 1}) + now = datetime.datetime.now() + repair_entry = { + 'repaired_by': session['username'], + 'repaired_at': now, + 'resolved_reports': open_reports + } + + result = items_col.update_one( + {'_id': ObjectId(id)}, + { + '$push': {'DamageRepairs': {'$each': [repair_entry], '$position': 0}}, + '$set': { + 'DamageReports': [], + 'HasDamage': False, + 'LastUpdated': now, + **({} if active_borrow else {'Verfuegbar': True}), + } + } + ) + + if not active_borrow: + items_col.update_one( + {'_id': ObjectId(id)}, + { + '$unset': {'User': '', 'Condition': ''} + } + ) + + if result.matched_count == 0: + return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 + + # Best-effort system log entry for repair action + try: + logs_collection = db['system_logs'] + logs_collection.insert_one({ + 'type': 'damage_repair', + 'timestamp': now.isoformat(), + 'user': session.get('username'), + 'item_id': id, + 'resolved_count': len(open_reports), + 'ip': request.remote_addr + }) + except Exception as log_err: + app.logger.warning(f"Damage repair log write failed for item {id}: {log_err}") + + return jsonify({ + 'success': True, + 'message': 'Schäden als repariert markiert.', + 'resolved_count': len(open_reports) + }) + except Exception as e: + app.logger.error(f"Error marking damages repaired for item {id}: {e}") + return jsonify({'success': False, 'message': 'Fehler beim Markieren als repariert.'}), 500 + finally: + if client: + client.close() + + +@app.route('/get_ausleihungen', methods=['GET']) +def get_ausleihungen(): + """ + API endpoint to retrieve all borrowing records. + + Returns: + dict: Dictionary containing all borrowing records + """ + ausleihungen = au.get_ausleihungen() + return {'ausleihungen': ausleihungen} + + +@app.route('/ausleihen/', methods=['POST']) +def ausleihen(id): + """ + Route for borrowing an item from inventory. + + Args: + id (str): ID of the item to borrow + + Returns: + flask.Response: Redirect to appropriate homepage + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + username = session['username'] + requested_return_to = (request.form.get('return_to') or '').strip().lower() + if requested_return_to == 'library' and cfg.LIBRARY_MODULE_ENABLED: + redirect_target = 'library_view' + else: + redirect_target = 'home_admin' if us.check_admin(username) else 'home' + + item = it.get_item(id) + if not item: + flash('Element nicht gefunden', 'error') + return redirect(url_for(redirect_target)) + + effective_borrower = username + borrow_duration_days = None + student_card_id = us.normalize_student_card_id(request.form.get('borrower_card_id')) + item_type = str(item.get('ItemType', '')).strip().lower() + is_library_item = item_type in LIBRARY_ITEM_TYPES + + # Library media can only be borrowed with a valid student card. + if is_library_item: + if not cfg.STUDENT_CARDS_MODULE_ENABLED: + flash('Bibliotheksmedien können nur mit aktivem Schülerausweis-Modul ausgeliehen werden.', 'error') + return redirect(url_for(redirect_target)) + if not student_card_id: + flash('Für Bibliotheksmedien ist eine gültige Schülerausweis-ID erforderlich.', 'error') + return redirect(url_for(redirect_target)) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + student_cards_col = db['student_cards'] + card_doc = student_cards_col.find_one({'AusweisId': student_card_id}) + if not card_doc: + flash('Ungültige Schülerausweis-ID. Bibliotheksmedien können nur mit gültigem Ausweis ausgeliehen werden.', 'error') + return redirect(url_for(redirect_target)) + + effective_borrower = card_doc.get('SchülerName') or f"Ausweis {student_card_id}" + + if not (request.form.get('borrow_duration_days') or '').strip(): + try: + card_default = int(card_doc.get('StandardAusleihdauer', cfg.STUDENT_DEFAULT_BORROW_DAYS)) + except (TypeError, ValueError): + card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS + borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS)) + finally: + if client: + client.close() + + if cfg.STUDENT_CARDS_MODULE_ENABLED: + duration_raw = (request.form.get('borrow_duration_days') or '').strip() + if duration_raw: + try: + parsed_duration = int(duration_raw) + if 1 <= parsed_duration <= cfg.STUDENT_MAX_BORROW_DAYS: + borrow_duration_days = parsed_duration + else: + flash(f'Ausleihdauer muss zwischen 1 und {cfg.STUDENT_MAX_BORROW_DAYS} Tagen liegen.', 'error') + return redirect(url_for(redirect_target)) + except ValueError: + flash('Ungültige Ausleihdauer angegeben.', 'error') + return redirect(url_for(redirect_target)) + + # Admins can borrow on behalf of students via student card id. + if us.check_admin(username) and not is_library_item: + if student_card_id: + student_user = us.get_user_by_student_card(student_card_id) + if not student_user: + flash('Keine Schülerin/kein Schüler mit dieser Ausweis-ID gefunden.', 'error') + return redirect(url_for('home_admin')) + effective_borrower = student_user.get('Username') or student_user.get('username') or username + if borrow_duration_days is None: + try: + card_default = int(student_user.get('MaxBorrowDays', cfg.STUDENT_DEFAULT_BORROW_DAYS)) + except (TypeError, ValueError): + card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS + borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS)) + + start_date = datetime.datetime.now() + end_date = None + if borrow_duration_days: + end_date = start_date + datetime.timedelta(days=borrow_duration_days) + + # Grouped inventory mode: parent item + hidden sub-items are handled as separate physical units. + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + grouped_children = list(items_col.find({'ParentItemId': id, 'IsGroupedSubItem': True})) + client.close() + except Exception: + grouped_children = [] + + if grouped_children: + exemplare_count = request.form.get('exemplare_count', 1) + specific_item_id = (request.form.get('specific_item_id') or '').strip() + try: + exemplare_count = int(exemplare_count) + if exemplare_count < 1: + exemplare_count = 1 + except (ValueError, TypeError): + exemplare_count = 1 + + grouped_units = [item] + [{**child, '_id': str(child.get('_id'))} for child in grouped_children] + available_units = [unit for unit in grouped_units if unit.get('Verfuegbar', True)] + + planned_counts = {} + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen_col = db['ausleihungen'] + unit_ids = [str(unit.get('_id')) for unit in grouped_units if unit.get('_id')] + planned_cursor = ausleihungen_col.find({'Item': {'$in': unit_ids}, 'Status': 'planned'}) + for planned_booking in planned_cursor: + planned_item_id = str(planned_booking.get('Item') or '') + if not planned_item_id: + continue + planned_counts[planned_item_id] = planned_counts.get(planned_item_id, 0) + 1 + client.close() + except Exception: + planned_counts = {} + + if not available_units: + flash('Keine Exemplare verfügbar.', 'error') + return redirect(url_for(redirect_target)) + + selected_units = [] + if specific_item_id: + chosen = next((unit for unit in available_units if str(unit.get('_id')) == specific_item_id), None) + if not chosen: + flash('Das ausgewählte Exemplar ist nicht verfügbar.', 'error') + return redirect(url_for(redirect_target)) + selected_units = [chosen] + else: + available_units.sort(key=lambda unit: (planned_counts.get(str(unit.get('_id')) or '', 0), str(unit.get('Code_4') or ''), str(unit.get('_id') or ''))) + if exemplare_count > len(available_units): + flash(f'Nicht genügend Exemplare verfügbar. Angefordert: {exemplare_count}, Verfügbar: {len(available_units)}', 'error') + return redirect(url_for(redirect_target)) + selected_units = available_units[:exemplare_count] + + for unit in selected_units: + unit_id = str(unit.get('_id')) + it.update_item_status(unit_id, False, effective_borrower) + au.add_ausleihung(unit_id, effective_borrower, start_date, end_date=end_date) + + if len(selected_units) == 1: + selected_code = selected_units[0].get('Code_4') or '-' + flash(f'Exemplar {selected_code} erfolgreich ausgeliehen', 'success') + else: + flash(f'{len(selected_units)} Exemplare erfolgreich ausgeliehen', 'success') + + return redirect(url_for(redirect_target)) + + # Before borrowing, block if there's a conflicting planned booking + try: + now = datetime.datetime.now() + # Fetch planned bookings for this item from DB + planned = au.get_planned_ausleihungen() + # Count relevant upcoming planned bookings for today or ongoing + upcoming_planned_today = [] + for appt in planned: + appt_item = str(appt.get('Item')) if appt.get('Item') is not None else None + if appt_item != id: + continue + appt_start = appt.get('Start') + appt_end = appt.get('End') or appt_start + if not appt_start: + continue + # Consider conflict if appointment ends in the future and is today + try: + if appt_end >= now and appt_start.date() == now.date(): + upcoming_planned_today.append(appt) + except Exception: + # Fallback simple check + if appt_start.date() == now.date(): + upcoming_planned_today.append(appt) + if upcoming_planned_today: + # For single-instance items, block outright; for multi-exemplar, allow only if capacity suffices + item_doc = it.get_item(id) + total_exemplare = item_doc.get('Exemplare', 1) if item_doc else 1 + if total_exemplare <= 1: + flash('Dieses Objekt hat heute eine geplante Reservierung und kann aktuell nicht ausgeliehen werden.', 'error') + return redirect(url_for(redirect_target)) + else: + # If planned count equals or exceeds remaining capacity, block + current_borrowed = len(item_doc.get('ExemplareStatus', [])) if item_doc else 0 + if current_borrowed + len(upcoming_planned_today) >= total_exemplare: + flash('Alle Exemplare sind aufgrund geplanter Reservierungen heute belegt.', 'error') + return redirect(url_for(redirect_target)) + except Exception as e: + print(f"Warning: could not enforce planned booking guard: {e}") + + # Get number of exemplars to borrow (default to 1) + exemplare_count = request.form.get('exemplare_count', 1) + try: + exemplare_count = int(exemplare_count) + if exemplare_count < 1: + exemplare_count = 1 + except (ValueError, TypeError): + exemplare_count = 1 + + # Check if the item has exemplars defined + total_exemplare = item.get('Exemplare', 1) + + # Get current exemplar status + exemplare_status = item.get('ExemplareStatus', []) + + # Count how many exemplars are currently available + borrowed_count = len(exemplare_status) + available_count = total_exemplare - borrowed_count + + if available_count < exemplare_count: + flash(f'Nicht genügend Exemplare verfügbar. Angefordert: {exemplare_count}, Verfügbar: {available_count}', 'error') + return redirect(url_for(redirect_target)) + + # If we reach here, we can borrow the requested number of exemplars + current_date = datetime.datetime.now().strftime('%d.%m.%Y %H:%M') + + # If the item doesn't use exemplars (single item) + if total_exemplare <= 1: + it.update_item_status(id, False, effective_borrower) + start_date = datetime.datetime.now() + au.add_ausleihung(id, effective_borrower, start_date, end_date=end_date) + flash('Element erfolgreich ausgeliehen', 'success') + else: + # Handle multi-exemplar item + new_borrowed_exemplars = [] + + # Create new entries for borrowed exemplars + for i in range(exemplare_count): + # Find the next available exemplar number + exemplar_number = 1 + used_numbers = [ex.get('number') for ex in exemplare_status] + + while exemplar_number in used_numbers: + exemplar_number += 1 + + new_borrowed_exemplars.append({ + 'number': exemplar_number, + 'user': effective_borrower, + 'date': current_date + }) + + # Add new borrowed exemplars to the status + updated_status = exemplare_status + new_borrowed_exemplars + + # Update the item with the new status + it.update_item_exemplare_status(id, updated_status) + + # Update the item's availability if all exemplars are borrowed + if len(updated_status) >= total_exemplare: + it.update_item_status(id, False, username) + + # Create ausleihung records for each borrowed exemplar + start_date = datetime.datetime.now() + for exemplar in new_borrowed_exemplars: + exemplar_id = f"{id}_{exemplar['number']}" + au.add_ausleihung(exemplar_id, effective_borrower, start_date, end_date=end_date, exemplar_data={ + 'parent_id': id, + 'exemplar_number': exemplar['number'] + }) + + flash(f'{exemplare_count} Exemplare erfolgreich ausgeliehen', 'success') + + return redirect(url_for(redirect_target)) + + +@app.route('/zurueckgeben/', methods=['POST']) +def zurueckgeben(id): + """ + Route for returning a borrowed item. + Creates or updates a record of the borrowing session. + + Args: + id (str): ID of the item to return + + Returns: + flask.Response: Redirect to appropriate homepage + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + item = it.get_item(id) + if not item: + flash('Element nicht gefunden', 'error') + return redirect(url_for('home')) + + username = session['username'] + + print("Code 1169: zurueckgeben called with item ID:", id) + + if not item.get('Verfuegbar', True) and (us.check_admin(session['username']) or item.get('User') == username): + print("Code 1172: Item is not available, proceeding with return") + try: + # Get ALL active borrowing records for this item and complete them + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Find all active records for this item + active_records = ausleihungen.find({ + 'Item': id, + 'Status': 'active' + }) + + end_date = datetime.datetime.now() + original_user = item.get('User', username) + + updated_count = 0 + for record in active_records: + ausleihung_id = str(record['_id']) + print(f"Completing active ausleihung {ausleihung_id} for item {id}") + + # Update each active record + result = ausleihungen.update_one( + {'_id': ObjectId(ausleihung_id)}, + {'$set': { + 'Status': 'completed', + 'End': end_date, + 'LastUpdated': datetime.datetime.now() + }} + ) + + if result.modified_count > 0: + updated_count += 1 + + client.close() + + # Update the item status + it.update_item_status(id, True, original_user) + + if updated_count > 0: + flash(f'Element erfolgreich zurückgegeben ({updated_count} Datensätze abgeschlossen)', 'success') + else: + flash('Element erfolgreich zurückgegeben', 'success') + + except Exception as e: + print(f"Error in return process: {e}") + it.update_item_status(id, True) + flash(f'Element zurückgegeben, aber ein Fehler ist aufgetreten: {str(e)}', 'warning') + else: + flash('Sie sind nicht berechtigt, dieses Element zurückzugeben, oder es ist bereits verfügbar', 'error') + + # Check if request came from my_borrowed_items page + source_page = request.form.get('source_page') + referrer = request.headers.get('Referer', '') + if source_page == 'my_borrowed_items' or '/my_borrowed_items' in referrer: + return redirect(url_for('my_borrowed_items')) + + if 'username' in session and not us.check_admin(session['username']): + return redirect(url_for('home')) + return redirect(url_for('home_admin')) + +@app.route('/get_filter', methods=['GET']) +def get_filter(): + """ + API endpoint to retrieve available item filters/categories. + + Returns: + dict: Dictionary of available filters + """ + return it.get_filters() + + +@app.route('/get_ausleihung_by_item/') +def get_ausleihung_by_item_route(id): + """ + API endpoint to retrieve borrowing details for a specific item. + + Args: + id (str): ID of the item to retrieve + + Returns: + dict: Borrowing details for the item + """ + if 'username' not in session: + return {'error': 'Not authorized', 'status': 'forbidden'}, 403 + + # Get the borrowing record + ausleihung = au.get_ausleihung_by_item(id, include_history=False) # Add client-side status verification if a borrowing record exists + if ausleihung: + # Add verified status to each borrowing record with logging + current_status = au.get_current_status( + ausleihung, + log_changes=True, + user=session.get('username', None) + ) + ausleihung['VerifiedStatus'] = current_status + + # Admin users can see all borrowing details + # Regular users can only see their own borrowings + if ausleihung and (us.check_admin(session['username']) or ausleihung.get('User') == session['username']): + return {'ausleihung': ausleihung, 'status': 'success'} + + # Get item name for better error message + item = it.get_item(id) + item_name = item.get('Name', 'Unknown') if item else 'Unknown' + + # Return a more informative error + return { + 'error': 'No active borrowing record found for this item', + 'item_name': item_name, + 'status': 'not_found' + }, 200 # Return 200 instead of 404 to allow processing of the error message + + +@app.route('/get_planned_bookings/') +def get_planned_bookings(item_id): + """ + Return all planned bookings for a given item (admin only). + """ + if 'username' not in session or not us.check_admin(session['username']): + return jsonify({'ok': False, 'error': 'unauthorized'}), 403 + + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1) + bookings = [] + for r in cursor: + bookings.append({ + 'id': str(r.get('_id')), + 'user': r.get('User', ''), + 'period': r.get('Period'), + 'start': r.get('Start').isoformat() if r.get('Start') else None, + 'end': r.get('End').isoformat() if r.get('End') else None, + 'notes': r.get('Notes', '') + }) + client.close() + return jsonify({'ok': True, 'bookings': bookings}) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}), 500 + + +@app.route('/get_planned_bookings_public/') +def get_planned_bookings_public(item_id): + """ + Return planned bookings for a given item (normal users; limited fields, no notes). + """ + if 'username' not in session: + return jsonify({'ok': False, 'error': 'unauthorized'}), 401 + + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1) + bookings = [] + for r in cursor: + bookings.append({ + 'period': r.get('Period'), + 'start': r.get('Start').isoformat() if r.get('Start') else None, + 'end': r.get('End').isoformat() if r.get('End') else None + }) + client.close() + return jsonify({'ok': True, 'bookings': bookings}) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}), 500 + + +@app.route('/check_availability') +def check_availability(): + """ + Check if a given item is available for the specified date and period range. + Query params: item_id, date=YYYY-MM-DD, start=<1-10>, end=<1-10> + Returns: { ok, available, conflicts:[...] } + """ + if 'username' not in session: + return jsonify({'ok': False, 'error': 'unauthorized'}), 401 + + item_id = request.args.get('item_id') + date_str = request.args.get('date') + start_p = request.args.get('start') + end_p = request.args.get('end') or start_p + if not item_id or not date_str or not start_p: + return jsonify({'ok': False, 'error': 'missing parameters'}), 400 + + try: + # Parse date + booking_date = datetime.datetime.strptime(date_str, '%Y-%m-%d') + start_num = int(start_p) + end_num = int(end_p) + if end_num < start_num: + start_num, end_num = end_num, start_num + + # Compute requested time window + start_times = get_period_times(booking_date, start_num) + end_times = get_period_times(booking_date, end_num) + if not start_times or not end_times: + return jsonify({'ok': False, 'error': 'invalid period(s)'}), 400 + req_start = start_times['start'] + req_end = end_times['end'] + + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + items_col = db['items'] + + # Collect potential conflicts (planned and active) for this day + same_day_start = datetime.datetime.combine(booking_date.date(), datetime.time.min) + same_day_end = datetime.datetime.combine(booking_date.date(), datetime.time.max) + candidates = list(ausleihungen.find({ + 'Item': item_id, + 'Status': {'$in': ['planned', 'active']}, + 'Start': {'$lte': same_day_end}, + 'End': {'$gte': same_day_start} + })) + + conflicts = [] + for r in candidates: + r_start = r.get('Start') + r_end = r.get('End') + # If end missing for active, assume lasts through the day + if r_end is None: + r_end = same_day_end + if r_start is None: + r_start = same_day_start + # Overlap check: req_start < r_end and req_end > r_start + if req_start < r_end and req_end > r_start: + conflicts.append({ + 'id': str(r.get('_id')), + 'status': r.get('Status'), + 'user': r.get('User', ''), + 'start': r_start.isoformat() if r_start else None, + 'end': r_end.isoformat() if r_end else None, + 'period': r.get('Period') + }) + + # Also include current availability if checking today and item is borrowed now + item_doc = items_col.find_one({'_id': ObjectId(item_id)}) + if item_doc and not item_doc.get('Verfuegbar', True): + now = datetime.datetime.now() + if req_start.date() == now.date(): + conflicts.append({'status': 'active', 'user': item_doc.get('User'), 'start': None, 'end': None, 'period': None, 'id': None}) + + client.close() + return jsonify({'ok': True, 'available': len(conflicts) == 0, 'conflicts': conflicts}) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}), 500 + + +# def create_qr_code(id): +# """ +# Generate a QR code for an item. +# The QR code contains a URL that points to the item details. +# +# Args: +# id (str): ID of the item to generate QR code for +# +# Returns: +# str: Filename of the generated QR code, or None if item not found +# """ +# qr = qrcode.QRCode( +# version=1, +# error_correction=ERROR_CORRECT_L, # Use imported constant +# box_size=10, +# border=4, +# ) +# +# # Parse and reconstruct the URL properly +# parsed_url = urlparse(request.url_root) +# +# # Force HTTPS if needed +# scheme = 'https' if parsed_url.scheme == 'http' else parsed_url.scheme +# +# # Properly reconstruct the base URL +# base_url = urlunparse((scheme, parsed_url.netloc, '', '', '', '')) +# +# # URL that will open this item directly +# item_url = f"{base_url}:{Port}/item/{id}" +# qr.add_data(item_url) +# qr.make(fit=True) +# +# item = it.get_item(id) +# if not item: +# return None +# +# img = qr.make_image(fill_color="black", back_color="white") +# +# # Create a unique filename using UUID +# unique_id = str(uuid.uuid4()) +# timestamp = time.strftime("%Y%m%d%H%M%S") +# +# # Still include the original name for readability but ensure uniqueness with UUID +# safe_name = secure_filename(item['Name']) +# filename = f"{safe_name}_{unique_id}_{timestamp}.png" +# qr_path = os.path.join(app.config['QR_CODE_FOLDER'], filename) +# +# +# # Fix the file handling - save to file object, not string +# with open(qr_path, 'wb') as f: +# img.save(f) +# +# return filename + +# Fix fromisoformat None value checks +@app.route('/plan_booking', methods=['POST']) +def plan_booking(): + """ + Create a new planned booking or a range of bookings + """ + if 'username' not in session: + return {"success": False, "error": "Not authenticated"}, 401 + + try: + # Extract form data + item_id = html.escape(request.form.get('item_id')) + start_date_str = html.escape(request.form.get('booking_date')) # Changed from start_date to booking_date + end_date_str = html.escape(request.form.get('booking_end_date')) # Changed from end_date to booking_end_date + period_start = html.escape(request.form.get('period_start')) + period_end = html.escape(request.form.get('period_end')) + notes = html.escape(request.form.get('notes', '')) + booking_type = html.escape(request.form.get('booking_type', 'single')) + + # Validate inputs + if not all([item_id, start_date_str, end_date_str, period_start]): + return {"success": False, "error": "Missing required fields"}, 400 + + # Parse dates + try: + if start_date_str: + start_date = datetime.datetime.fromisoformat(start_date_str) + else: + return {"success": False, "error": "Missing start date"}, 400 + + if end_date_str: + end_date = datetime.datetime.fromisoformat(end_date_str) + else: + return {"success": False, "error": "Missing end date"}, 400 + + # For single day bookings, use the start date as the end date + if booking_type == 'single': + end_date = start_date + except ValueError as e: + return {"success": False, "error": f"Invalid date format: {e}"}, 400 + + # Check if item exists + item = it.get_item(item_id) + if not item: + return {"success": False, "error": "Item not found"}, 404 + + # Check if item is reservable + if not item.get('Reservierbar', True): + return {"success": False, "error": "Dieses Item kann nicht reserviert werden."}, 400 + + # Handle period range + periods = [] + if period_start: + period_start_num = int(period_start) + else: + period_start_num = 1 # Default if None + + # If period_end is provided, it's a range of periods + if period_end: + period_end_num = int(period_end) + + # Validate period range + if period_end_num < period_start_num: + return {"success": False, "error": "End period cannot be before start period"}, 400 + + # Create list of all periods in the range + periods = list(range(period_start_num, period_end_num + 1)) + else: + # Single period booking + periods = [period_start_num] + + # For date range bookings, we'll process each date separately + booking_ids = [] + errors = [] + + # If it's a range of days + if booking_type == 'range' and start_date != end_date: + current_date = start_date + while current_date <= end_date: + # For each day in the range + day_booking_ids, day_errors = process_day_bookings( + item_id, + current_date, + periods, + notes + ) + booking_ids.extend(day_booking_ids) + errors.extend(day_errors) + + # Move to next day + current_date += datetime.timedelta(days=1) + else: + # Single day with multiple periods + booking_ids, errors = process_day_bookings( + item_id, + start_date, + periods, + notes + ) + + # Return results + if errors: + if booking_ids: + # Some succeeded, some failed + return { + "success": True, + "partial": True, + "booking_ids": booking_ids, + "errors": errors + } + else: + # All failed + return {"success": False, "errors": errors}, 400 + else: + # All succeeded + return {"success": True, "booking_ids": booking_ids} + + except Exception as e: + import traceback + print(f"Error in plan_booking: {e}") + traceback.print_exc() + return {"success": False, "error": f"Serverfehler: {str(e)}"}, 500 + +def process_day_bookings(item_id, booking_date, periods, notes): + """ + Helper function to process bookings for a single day across multiple periods + + Args: + item_id: The item to book + booking_date: The date for the booking + periods: List of period numbers to book + notes: Booking notes + + Returns: + tuple: (list of booking_ids, list of errors) + """ + booking_ids = [] + errors = [] + + for period in periods: + # Get period times + period_times = get_period_times(booking_date, period) + if not period_times: + errors.append(f"Invalid period {period}") + continue + + # Create the start and end times for this period + start_time = period_times.get('start') + end_time = period_times.get('end') + + # Check for conflicts + if au.check_booking_conflict(item_id, start_time, end_time, period): + errors.append(f"Conflict for period {period} on {booking_date.strftime('%Y-%m-%d')}") + continue + + # Create the booking + booking_id = au.add_planned_booking( + item_id, + session['username'], + start_time, + end_time, + notes, + period=period + ) + + if booking_id: + booking_ids.append(str(booking_id)) + else: + errors.append(f"Failed to create booking for period {period}") + + return booking_ids, errors +@app.route('/add_booking', methods=['POST']) +def add_booking(): + if 'username' not in session: + return jsonify({'success': False, 'error': 'Not logged in'}) + + item_id = html.escape(request.form.get('item_id')) + + # Check if item exists and is reservable + item = it.get_item(item_id) + if not item: + return jsonify({'success': False, 'error': 'Item not found'}) + + if not item.get('Reservierbar', True): + return jsonify({'success': False, 'error': 'Dieses Item kann nicht reserviert werden.'}) + + start_date_str = request.form.get('start_date') + end_date_str = request.form.get('end_date') + period = request.form.get('period') + notes = request.form.get('notes', '') + + # Parse dates as naive datetime objects + try: + # Simple datetime parsing without timezone + if start_date_str: + start_date = datetime.datetime.strptime(start_date_str, '%Y-%m-%d %H:%M:%S') + else: + return jsonify({'success': False, 'error': 'Missing start date'}) + + if end_date_str: + end_date = datetime.datetime.strptime(end_date_str, '%Y-%m-%d %H:%M:%S') + else: + end_date = None + + # Continue with adding the booking + booking_id = au.add_planned_booking( + item_id=item_id, + user=session['username'], + start_date=start_date, + end_date=end_date, + notes=notes, + period=period + ) + + return jsonify({'success': True, 'booking_id': str(booking_id)}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + +@app.route('/cancel_booking/', methods=['POST']) +def cancel_booking(id): + """ + Cancel a planned booking + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Get the booking + booking = au.get_booking(id) + if not booking: + return {"success": False, "error": "Booking not found"}, 404 + + # Check if user owns this booking + if booking.get('User') != session['username'] and not us.check_admin(session['username']): + return {"success": False, "error": "Not authorized to cancel this booking"}, 403 + + # Cancel the booking + result = au.cancel_booking(id) + + if result: + return {"success": True} + else: + return {"success": False, "error": "Failed to cancel booking"} + +@app.route('/terminplan', methods=['GET']) +def terminplan(): + """ + Route to display the booking calendar + """ + try: + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Make sure the template exists + template_path = os.path.join(BASE_DIR, 'templates', 'terminplan.html') + if not os.path.exists(template_path): + print(f"Template file not found: {template_path}") + flash('Vorlage nicht gefunden. Bitte kontaktieren Sie den Administrator.', 'error') + return redirect(url_for('home')) + + return render_template('terminplan.html', school_periods=SCHOOL_PERIODS) + except Exception as e: + import traceback + print(f"Error rendering terminplan: {e}") + traceback.print_exc() + flash('Ein Fehler ist beim Anzeigen des Kalenders aufgetreten.', 'error') + return redirect(url_for('home')) + + +'''-------------------------------------------------------------------------------------------------------------ADMIN ROUTES------------------------------------------------------------------------------------------------------------------''' + +@app.route('/register', methods=['GET', 'POST']) +def register(): + """ + User registration route.false + Returns: + flask.Response: Rendered template or redirect + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if 'username' in session and us.check_admin(session['username']): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + name = request.form['name'] + last_name = request.form['last-name'] + 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: + flash('Bitte füllen Sie alle Felder aus', 'error') + return redirect(url_for('register')) + if us.get_user(username): + flash('Benutzer existiert bereits', 'error') + return redirect(url_for('register')) + if not us.check_password_strength(password): + flash('Passwort ist zu schwach', 'error') + return redirect(url_for('register')) + + 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 + ) + return redirect(url_for('home')) + return render_template( + 'register.html', + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, + student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS + ) + flash('Sie sind nicht berechtigt, diese Seite anzuzeigen', 'error') + return redirect(url_for('login')) + + +@app.route('/user_del', methods=['GET']) +def user_del(): + """ + User deletion interface. + Displays a list of users that can be deleted by an administrator. + Prevents self-deletion by hiding the current user from the list. + + Returns: + flask.Response: Rendered template with user list or redirect + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + all_users = us.get_all_users() + + users_list = [] + for user in all_users: + username = None + for field in ['Username']: + if field in user: + username = user[field] + break + + if username and username != session['username']: + try: + name = us.get_name(username) + last_name = us.get_last_name(username) + if name and last_name: + fullname = f"{name} {last_name}" + elif name: + fullname = name + elif last_name: + fullname = last_name + else: + fullname = None + except: + name = "" + last_name = "" + fullname = None + users_list.append({ + 'username': username, + 'admin': user.get('Admin', False), + 'fullname': fullname, + 'name': name, + 'last_name': last_name + }) + + return render_template( + 'user_del.html', + users=users_list, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED + ) + + +@app.route('/delete_user', methods=['POST']) +def delete_user(): + """ + Process user deletion request. + Deletes a specified user from the system. + Includes safety checks to prevent self-deletion. + + Returns: + flask.Response: Redirect to the user deletion interface with status + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + username = request.form.get('username') + if not username: + flash('Kein Benutzer ausgewählt', 'error') + return redirect(url_for('user_del')) + + # Prevent self-deletion + if username == session['username']: + flash('Sie können Ihr eigenes Konto nicht löschen', 'error') + return redirect(url_for('user_del')) + + # Reset this user's borrowings and free items before deleting the user + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + items_col = db['items'] + now = datetime.datetime.now() + + # Complete all active borrowings of this user + ausleihungen.update_many( + {'User': username, 'Status': 'active'}, + {'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}} + ) + + # Cancel all planned borrowings of this user + ausleihungen.update_many( + {'User': username, 'Status': 'planned'}, + {'$set': {'Status': 'cancelled', 'LastUpdated': now}} + ) + + # Free all items currently associated with this user + items_col.update_many( + {'User': username}, + {'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}} + ) + + client.close() + except Exception as e: + flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden: {str(e)}', 'warning') + + # Delete the user + try: + us.delete_user(username) + flash(f'Benutzer {username} erfolgreich gelöscht', 'success') + except Exception as e: + flash(f'Fehler beim Löschen des Benutzers: {str(e)}', 'error') + + return redirect(url_for('user_del')) + + +@app.route('/admin/borrowings') +def admin_borrowings(): + """ + Admin view: list all active and planned borrowings with ability to reset. + """ + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + items_col = db['items'] + + # Load active and planned borrowings + records = list(ausleihungen.find({'Status': {'$in': ['active', 'planned']}}).sort('Start', -1)) + + def fmt_dt(dt): + try: + return dt.strftime('%d.%m.%Y %H:%M') if dt else '' + except Exception: + return str(dt) if dt else '' + + def fmt_money(value): + return _format_money_value(value) + + entries = [] + for r in records: + it_id = r.get('Item') + item_doc = it.get_item(it_id) + invoice_data = r.get('InvoiceData') or {} + try: + item_code = item_doc.get('Code_4') + item_name = item_doc.get('Name') + item_cost = item_doc.get('Anschaffungskosten') + condition_value = str(item_doc.get('Condition', '')).strip().lower() + has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(item_doc.get('DamageReports')) + except: + item_code = None + item_name = None + item_cost = None + has_damage = False + entries.append({ + 'id': str(r.get('_id')), + 'item_id': str(item_doc.get('_id')) if item_doc and item_doc.get('_id') else str(it_id or ''), + 'item_code': str(item_code) if item_code is not None else '', + 'item_name': str(item_name or ''), + 'item_cost': fmt_money(item_cost), + 'item_cost_raw': item_cost if item_cost is not None else '', + 'user': r.get('User', ''), + 'status': r.get('Status', ''), + 'start': fmt_dt(r.get('Start')), + 'end': fmt_dt(r.get('End')), + 'period': r.get('Period') if r.get('Period') is not None else '', + 'notes': r.get('Notes', ''), + 'invoice_number': invoice_data.get('invoice_number', ''), + 'invoice_amount': fmt_money(invoice_data.get('amount')) if invoice_data.get('amount') is not None else fmt_money(item_cost), + 'invoice_reason': invoice_data.get('damage_reason', ''), + 'invoice_created_at': fmt_dt(invoice_data.get('created_at')) if isinstance(invoice_data.get('created_at'), datetime.datetime) else '', + 'invoice_paid': bool(invoice_data.get('paid', False)), + 'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '', + 'has_damage': has_damage, + }) + + client.close() + + return render_template( + 'admin_borrowings.html', + entries=entries, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED + ) + + +@app.route('/admin/reset_borrowing/', methods=['POST']) +def admin_reset_borrowing(borrow_id): + """ + Admin action: reset a single borrowing. + - If active: complete it and free the item + - If planned: cancel it + """ + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + items_col = db['items'] + + rec = ausleihungen.find_one({'_id': ObjectId(borrow_id)}) + if not rec: + client.close() + flash('Ausleihung nicht gefunden', 'error') + return redirect(url_for('admin_borrowings')) + + status = rec.get('Status') + item_id = rec.get('Item') + user = rec.get('User') + + now = datetime.datetime.now() + if status == 'active': + ausleihungen.update_one({'_id': rec['_id']}, {'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}}) + # Free the item + if item_id: + try: + items_col.update_one({'_id': ObjectId(item_id)}, {'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}}) + except Exception: + pass + flash('Aktive Ausleihe wurde zurückgesetzt (abgeschlossen).', 'success') + elif status == 'planned': + ausleihungen.update_one({'_id': rec['_id']}, {'$set': {'Status': 'cancelled', 'LastUpdated': now}}) + flash('Geplante Ausleihe wurde storniert.', 'success') + else: + flash('Diese Ausleihe ist weder aktiv noch geplant.', 'warning') + + client.close() + except Exception as e: + flash(f'Fehler beim Zurücksetzen: {str(e)}', 'error') + + return redirect(url_for('admin_borrowings')) + + +@app.route('/admin/borrowings//invoice', methods=['POST']) +def admin_create_invoice(borrow_id): + """Create a PDF invoice for a destroyed borrowed item.""" + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + items_col = db['items'] + + borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}) + if not borrow_doc: + flash('Ausleihung nicht gefunden.', 'error') + return redirect(url_for('admin_borrowings')) + + item_id = borrow_doc.get('Item') + item_doc = None + if item_id: + try: + item_doc = items_col.find_one({'_id': ObjectId(item_id)}) + except Exception: + item_doc = items_col.find_one({'_id': item_id}) + + if not item_doc: + flash('Zugehöriges Element nicht gefunden.', 'error') + return redirect(url_for('admin_borrowings')) + + if borrow_doc.get('Status') not in ['active', 'completed'] and not borrow_doc.get('InvoiceData'): + flash('Eine Rechnung kann nur für aktive oder bereits dokumentierte Ausleihungen erstellt werden.', 'warning') + return redirect(url_for('admin_borrowings')) + + amount_raw = request.form.get('invoice_amount') or request.form.get('amount') or item_doc.get('Anschaffungskosten') + amount_value = _parse_money_value(amount_raw) + if amount_value is None or amount_value <= 0: + flash('Bitte einen gültigen Rechnungsbetrag angeben.', 'error') + return redirect(url_for('admin_borrowings')) + + damage_reason = str(request.form.get('damage_reason', '')).strip() or 'Das ausgeliehene Element wurde beschädigt oder zerstört.' + if len(damage_reason) > 2000: + flash('Die Schadensbeschreibung ist zu lang.', 'error') + return redirect(url_for('admin_borrowings')) + + mark_destroyed = request.form.get('mark_destroyed') == 'on' + close_borrowing = request.form.get('close_borrowing') == 'on' + now = datetime.datetime.now() + + existing_invoice = borrow_doc.get('InvoiceData') or {} + invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now) + borrower = borrow_doc.get('User', '') + item_name = item_doc.get('Name', '') + item_code = item_doc.get('Code_4', '') + + invoice_data = { + 'invoice_number': invoice_number, + 'amount': round(amount_value, 2), + 'amount_text': _format_money_value(amount_value), + 'damage_reason': damage_reason, + 'created_at': now, + 'created_by': session.get('username', ''), + 'borrower': borrower, + 'item_id': str(item_doc['_id']), + 'item_name': item_name, + 'item_code': item_code, + 'mark_destroyed': mark_destroyed, + 'status_before_invoice': borrow_doc.get('Status', ''), + 'paid': False, + 'paid_at': None, + 'paid_by': '', + } + + update_fields = { + 'InvoiceData': invoice_data, + 'LastUpdated': now, + } + if close_borrowing: + update_fields['Status'] = 'completed' + update_fields['End'] = now + + ausleihungen.update_one({'_id': borrow_doc['_id']}, {'$set': update_fields}) + + if mark_destroyed: + damage_entry = { + 'description': damage_reason, + 'reported_by': session['username'], + 'reported_at': now, + 'invoice_number': invoice_number, + 'invoice_amount': round(amount_value, 2), + 'source': 'invoice' + } + items_col.update_one( + {'_id': item_doc['_id']}, + { + '$push': {'DamageReports': {'$each': [damage_entry], '$position': 0}}, + '$set': { + 'HasDamage': True, + 'Verfuegbar': False, + 'LastUpdated': now, + 'Condition': 'destroyed' + }, + '$unset': {'User': ''} + } + ) + + try: + logs_collection = db['system_logs'] + logs_collection.insert_one({ + 'type': 'damage_invoice', + 'timestamp': now.isoformat(), + 'user': session.get('username'), + 'borrow_id': borrow_id, + 'item_id': str(item_doc['_id']), + 'item_name': item_name, + 'invoice_number': invoice_number, + 'amount': round(amount_value, 2), + 'ip': request.remote_addr, + }) + except Exception as log_err: + app.logger.warning(f"Damage invoice log write failed for borrow {borrow_id}: {log_err}") + + pdf_buffer = _build_invoice_pdf(invoice_data) + return send_file( + pdf_buffer, + mimetype='application/pdf', + as_attachment=True, + download_name=f'rechnung_{invoice_number}.pdf' + ) + except Exception as e: + app.logger.error(f"Error creating damage invoice for borrow {borrow_id}: {e}") + flash('Fehler beim Erstellen der Rechnung.', 'error') + return redirect(url_for('admin_borrowings')) + finally: + if client: + client.close() + + +@app.route('/admin/borrowings//invoice/mark-paid', methods=['POST']) +def admin_mark_invoice_paid(borrow_id): + """Mark an existing invoice as paid.""" + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + + borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}, {'InvoiceData': 1}) + if not borrow_doc: + flash('Ausleihung nicht gefunden.', 'error') + return redirect(url_for('admin_borrowings')) + + invoice_data = borrow_doc.get('InvoiceData') or {} + if not invoice_data: + flash('Für diese Ausleihung existiert keine Rechnung.', 'warning') + return redirect(url_for('admin_borrowings')) + + if invoice_data.get('paid') is True: + flash('Rechnung ist bereits als bezahlt markiert.', 'info') + return redirect(url_for('admin_borrowings')) + + now = datetime.datetime.now() + result = ausleihungen.update_one( + {'_id': borrow_doc['_id']}, + { + '$set': { + 'InvoiceData.paid': True, + 'InvoiceData.paid_at': now, + 'InvoiceData.paid_by': session.get('username', ''), + 'LastUpdated': now, + } + } + ) + + if result.matched_count == 0: + flash('Ausleihung nicht gefunden.', 'error') + return redirect(url_for('admin_borrowings')) + + try: + logs_collection = db['system_logs'] + logs_collection.insert_one({ + 'type': 'damage_invoice_paid', + 'timestamp': now.isoformat(), + 'user': session.get('username'), + 'borrow_id': borrow_id, + 'invoice_number': invoice_data.get('invoice_number', ''), + 'amount': invoice_data.get('amount'), + 'ip': request.remote_addr, + }) + except Exception as log_err: + app.logger.warning(f"Damage invoice paid log write failed for borrow {borrow_id}: {log_err}") + + flash('Rechnung wurde als bezahlt markiert.', 'success') + return redirect(url_for('admin_borrowings')) + except Exception as e: + app.logger.error(f"Error marking invoice paid for borrow {borrow_id}: {e}") + flash('Fehler beim Markieren als bezahlt.', 'error') + return redirect(url_for('admin_borrowings')) + finally: + if client: + client.close() + + +@app.route('/admin/borrowings//invoice/finalize', methods=['POST']) +def admin_finalize_invoice_and_repair(borrow_id): + """Mark invoice as paid and item as repaired in one action.""" + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + items_col = db['items'] + + borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}) + if not borrow_doc: + flash('Ausleihung nicht gefunden.', 'error') + return redirect(url_for('admin_borrowings')) + + invoice_data = borrow_doc.get('InvoiceData') or {} + if not invoice_data: + flash('Für diese Ausleihung existiert keine Rechnung.', 'warning') + return redirect(url_for('admin_borrowings')) + + item_doc = None + item_id = borrow_doc.get('Item') + if item_id: + try: + item_doc = items_col.find_one({'_id': ObjectId(item_id)}) + except Exception: + item_doc = items_col.find_one({'_id': item_id}) + + now = datetime.datetime.now() + + update_fields = { + 'LastUpdated': now, + } + if invoice_data.get('paid') is not True: + update_fields.update({ + 'InvoiceData.paid': True, + 'InvoiceData.paid_at': now, + 'InvoiceData.paid_by': session.get('username', ''), + }) + if borrow_doc.get('Status') == 'active': + update_fields['Status'] = 'completed' + update_fields['End'] = now + + ausleihungen.update_one({'_id': borrow_doc['_id']}, {'$set': update_fields}) + + repaired = False + resolved_count = 0 + if item_doc: + open_reports = item_doc.get('DamageReports', []) or [] + resolved_count = len(open_reports) + item_update = { + '$set': { + 'DamageReports': [], + 'HasDamage': False, + 'Verfuegbar': True, + 'LastUpdated': now, + }, + '$unset': { + 'User': '', + 'Condition': '', + }, + } + if open_reports: + repair_entry = { + 'repaired_by': session['username'], + 'repaired_at': now, + 'resolved_reports': open_reports, + } + item_update['$push'] = {'DamageRepairs': {'$each': [repair_entry], '$position': 0}} + + item_result = items_col.update_one({'_id': item_doc['_id']}, item_update) + repaired = item_result.matched_count > 0 + + try: + logs_collection = db['system_logs'] + logs_collection.insert_one({ + 'type': 'damage_invoice_finalize', + 'timestamp': now.isoformat(), + 'user': session.get('username'), + 'borrow_id': borrow_id, + 'item_id': str(item_doc.get('_id')) if item_doc else '', + 'invoice_number': invoice_data.get('invoice_number', ''), + 'amount': invoice_data.get('amount'), + 'repaired': repaired, + 'resolved_damage_reports': resolved_count, + 'ip': request.remote_addr, + }) + except Exception as log_err: + app.logger.warning(f"Damage invoice finalize log write failed for borrow {borrow_id}: {log_err}") + + if repaired: + flash('Rechnung als bezahlt markiert und Element als repariert abgeschlossen.', 'success') + else: + flash('Rechnung als bezahlt markiert. Element konnte nicht repariert werden (nicht gefunden).', 'warning') + return redirect(url_for('admin_borrowings')) + except Exception as e: + app.logger.error(f"Error finalizing invoice/repair for borrow {borrow_id}: {e}") + flash('Fehler beim Kombinieren von bezahlt und repariert.', 'error') + return redirect(url_for('admin_borrowings')) + finally: + if client: + client.close() + + +@app.route('/admin/borrowings//invoice/pdf', methods=['GET']) +def admin_view_invoice_pdf(borrow_id): + """View a previously created invoice PDF for a borrowing.""" + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + ausleihungen = db['ausleihungen'] + items_col = db['items'] + + borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}, {'InvoiceData': 1, 'Item': 1, 'User': 1}) + if not borrow_doc: + flash('Ausleihung nicht gefunden.', 'error') + return redirect(url_for('library_loans_admin')) + + invoice_data = borrow_doc.get('InvoiceData') or {} + if not invoice_data: + flash('Für diese Ausleihe wurde noch keine Rechnung erstellt.', 'warning') + return redirect(url_for('library_loans_admin')) + + item_doc = None + item_id = borrow_doc.get('Item') + if item_id: + try: + item_doc = items_col.find_one({'_id': ObjectId(item_id)}) + except Exception: + item_doc = items_col.find_one({'_id': item_id}) + + pdf_payload = _prepare_invoice_pdf_payload(invoice_data, borrow_doc=borrow_doc, item_doc=item_doc) + invoice_number = pdf_payload.get('invoice_number', 'rechnung') + pdf_buffer = _build_invoice_pdf(pdf_payload) + return send_file( + pdf_buffer, + mimetype='application/pdf', + as_attachment=False, + download_name=f'rechnung_{invoice_number}.pdf' + ) + except Exception as e: + app.logger.error(f"Error loading stored invoice PDF for borrow {borrow_id}: {e}") + flash('Fehler beim Öffnen der Rechnung.', 'error') + return redirect(url_for('library_loans_admin')) + finally: + if client: + client.close() + + +@app.route('/admin/library/items//invoices', methods=['GET']) +def library_item_invoices(item_id): + """Show all stored invoices for one specific library item.""" + if 'username' not in session or not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not cfg.LIBRARY_MODULE_ENABLED: + flash('Bibliotheks-Modul ist deaktiviert.', 'error') + return redirect(url_for('home_admin')) + + client = None + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + ausleihungen = db['ausleihungen'] + + try: + item_doc = items_col.find_one({'_id': ObjectId(item_id)}) + except Exception: + item_doc = items_col.find_one({'_id': item_id}) + + if not item_doc: + flash('Bibliotheksmedium nicht gefunden.', 'error') + return redirect(url_for('library_loans_admin')) + + borrow_docs = list(ausleihungen.find( + { + 'Item': str(item_doc.get('_id')), + 'InvoiceData': {'$exists': True, '$ne': {}} + }, + { + 'Status': 1, + 'User': 1, + 'Start': 1, + 'End': 1, + 'InvoiceData': 1, + } + ).sort('InvoiceData.created_at', -1)) + + entries = [] + for borrow_doc in borrow_docs: + invoice_data = borrow_doc.get('InvoiceData') or {} + created_at = invoice_data.get('created_at') + created_at_display = created_at.strftime('%d.%m.%Y %H:%M') if isinstance(created_at, datetime.datetime) else (str(created_at) if created_at else '') + paid_at = invoice_data.get('paid_at') + paid_at_display = paid_at.strftime('%d.%m.%Y %H:%M') if isinstance(paid_at, datetime.datetime) else (str(paid_at) if paid_at else '') + + entries.append({ + 'borrow_id': str(borrow_doc.get('_id')), + 'borrow_status': borrow_doc.get('Status', ''), + 'borrow_user': borrow_doc.get('User', ''), + 'borrow_start': borrow_doc.get('Start').strftime('%d.%m.%Y %H:%M') if isinstance(borrow_doc.get('Start'), datetime.datetime) else '', + 'borrow_end': borrow_doc.get('End').strftime('%d.%m.%Y %H:%M') if isinstance(borrow_doc.get('End'), datetime.datetime) else '', + 'invoice_number': invoice_data.get('invoice_number', ''), + 'invoice_amount': _format_money_value(invoice_data.get('amount')), + 'invoice_reason': invoice_data.get('damage_reason', ''), + 'invoice_created_at': created_at_display, + 'invoice_created_by': invoice_data.get('created_by', ''), + 'invoice_paid': bool(invoice_data.get('paid', False)), + 'invoice_paid_at': paid_at_display, + 'invoice_paid_by': invoice_data.get('paid_by', ''), + }) + + return render_template( + 'library_item_invoices.html', + item={ + 'id': str(item_doc.get('_id')), + 'name': item_doc.get('Name', ''), + 'code': item_doc.get('Code_4', ''), + 'author': item_doc.get('Author', ''), + 'isbn': item_doc.get('ISBN', ''), + }, + invoices=entries, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, + ) + except Exception as e: + app.logger.error(f"Error loading invoice history for item {item_id}: {e}") + flash('Fehler beim Laden der Rechnungshistorie.', 'error') + return redirect(url_for('library_loans_admin')) + finally: + if client: + client.close() + + +@app.route('/admin_reset_user_password', methods=['POST']) +def admin_reset_user_password(): + """ + Admin route to reset a user's password. + Resets the password for the specified user to a temporary password. + Only accessible by administrators. + + Returns: + flask.Response: Redirect to user management page with status message + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + username = request.form.get('username') + new_password = html.escape(request.form.get('new_password', 'Password123')) # Default temporary password + + if not username: + flash('Kein Benutzer ausgewählt', 'error') + return redirect(url_for('user_del')) + + # Check if user exists + user = us.get_user(username) + if not user: + flash(f'Benutzer {username} nicht gefunden', 'error') + return redirect(url_for('user_del')) + + # Prevent changing own password through this route (use change_password instead) + if username == session['username']: + flash('Sie können Ihr eigenes Passwort nicht über diese Funktion ändern. Bitte verwenden Sie dafür die Option "Passwort ändern" im Profil-Menü.', 'error') + return redirect(url_for('user_del')) + + # Reset the password + try: + us.update_password(username, new_password) + flash(f'Passwort für {username} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success') + except Exception as e: + flash(f'Fehler beim Zurücksetzen des Passworts: {str(e)}', 'error') + + return redirect(url_for('user_del')) + + +@app.route('/admin_update_user_name', methods=['POST']) +def admin_update_user_name(): + """ + Admin route to update a user's name details. + + Returns: + flask.Response: Redirect to user management page + """ + if 'username' not in session or not us.check_admin(session['username']): + flash('Nicht autorisierter Zugriff', 'error') + return redirect(url_for('login')) + + username = request.form.get('username') + name = html.escape(request.form.get('name')) + last_name = html.escape(request.form.get('last_name')) + + if not username: + flash('Kein Benutzer ausgewählt', 'error') + return redirect(url_for('user_del')) + + if us.update_user_name(username, name, last_name): + flash(f'Name für {username} wurde erfolgreich aktualisiert.', 'success') + else: + flash(f'Fehler beim Aktualisieren des Namens.', 'error') + + return redirect(url_for('user_del')) + + +@app.route('/logs') +def logs(): + """ + View system logs interface. + Displays a history of all item borrowings with detailed information. + + Returns: + flask.Response: Rendered template with logs or redirect if not authenticated + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Get ausleihungen + all_ausleihungen = au.get_ausleihungen() + + formatted_items = [] + for ausleihung in all_ausleihungen: + try: + # Get item details - from sample data, Item is an ID + item = it.get_item(ausleihung.get('Item')) + item_name = item.get('Name', 'Unknown Item') if item else 'Unknown Item' + + # Get user details - from sample data, User is a username string + + + username = ausleihung.get('User', 'Unknown User') + # Determine (verified) status for display + try: + display_status = au.get_current_status(ausleihung) + except Exception: + display_status = ausleihung.get('Status', 'unknown') + + # Format dates for display + start_date = ausleihung.get('Start') + if isinstance(start_date, datetime.datetime): + start_date = start_date.strftime('%Y-%m-%d %H:%M') + + end_date = ausleihung.get('End', 'Not returned') + if isinstance(end_date, datetime.datetime): + end_date = end_date.strftime('%Y-%m-%d %H:%M') + + # Calculate duration + duration = 'N/A' + if isinstance(ausleihung.get('Start'), datetime.datetime) and isinstance(ausleihung.get('End'), datetime.datetime): + duration_td = ausleihung['End'] - ausleihung['Start'] + hours, remainder = divmod(duration_td.seconds, 3600) + minutes, _ = divmod(remainder, 60) + if duration_td.days > 0: + duration = f"{duration_td.days}d {hours}h {minutes}m" + else: + duration = f"{hours}h {minutes}m" + + formatted_items.append({ + 'Item': item_name, + 'User': username, + 'Start': start_date, + 'End': end_date, + 'Duration': duration, + 'Status': display_status, + 'EventType': 'Ausleihe', + 'id': str(ausleihung['_id']), + 'ConflictDetected': ausleihung.get('ConflictDetected', False), + 'ConflictNote': ausleihung.get('ConflictNote', ''), + }) + except Exception as e: + continue + + # Add damage + repair entries from system_logs + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + logs_collection = db['system_logs'] + extra_logs = list(logs_collection.find({'type': {'$in': ['damage_report', 'damage_repair']}})) + + for log_item in extra_logs: + log_type = log_item.get('type', '') + item_id = log_item.get('item_id') + item_obj = it.get_item(item_id) if item_id else None + item_name = item_obj.get('Name', 'Unknown Item') if item_obj else 'Unknown Item' + ts_raw = log_item.get('timestamp') + ts_display = ts_raw or '-' + if isinstance(ts_raw, datetime.datetime): + ts_display = ts_raw.strftime('%Y-%m-%d %H:%M') + elif isinstance(ts_raw, str): + try: + ts_display = datetime.datetime.fromisoformat(ts_raw).strftime('%Y-%m-%d %H:%M') + except Exception: + ts_display = ts_raw + + if log_type == 'damage_report': + note = log_item.get('note', '') + formatted_items.append({ + 'Item': item_name, + 'User': log_item.get('user', 'Unknown User'), + 'Start': ts_display, + 'End': '-', + 'Duration': '-', + 'Status': 'gemeldet', + 'EventType': 'Schaden', + 'id': str(log_item.get('_id', '')), + 'ConflictDetected': False, + 'ConflictNote': note, + }) + elif log_type == 'damage_repair': + resolved_count = log_item.get('resolved_count', 0) + formatted_items.append({ + 'Item': item_name, + 'User': log_item.get('user', 'Unknown User'), + 'Start': ts_display, + 'End': '-', + 'Duration': '-', + 'Status': 'repariert', + 'EventType': 'Reparatur', + 'id': str(log_item.get('_id', '')), + 'ConflictDetected': False, + 'ConflictNote': f'Erledigte Schäden: {resolved_count}', + }) + client.close() + except Exception as e: + app.logger.warning(f"Could not load damage/repair logs: {e}") + + def parse_sort_date(value): + if isinstance(value, datetime.datetime): + return value + if isinstance(value, str): + for fmt in ('%Y-%m-%d %H:%M', '%Y-%m-%d'): + try: + return datetime.datetime.strptime(value, fmt) + except Exception: + continue + try: + return datetime.datetime.fromisoformat(value) + except Exception: + return datetime.datetime.min + return datetime.datetime.min + + formatted_items.sort(key=lambda entry: parse_sort_date(entry.get('Start')), reverse=True) + + return render_template( + 'logs.html', + items=formatted_items, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED + ) + + +@app.route('/get_logs', methods=['GET']) +def get_logs(): + """ + API endpoint to retrieve all borrowing logs. + + Returns: + dict: Dictionary containing all borrowing records or redirect if not authenticated + """ + if not session.get('username'): + return redirect(url_for('login')) + logs = au.get_ausleihungen() + return logs + + +@app.route('/get_usernames', methods=['GET']) +def get_usernames(): + """ + API endpoint to retrieve all usernames from the system. + Requires administrator privileges. + + Returns: + dict: Dictionary containing all users or redirect if not authenticated + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('logout')) + elif 'username' in session and us.check_admin(session['username']): + return jsonify(us.get_all_users()) # Fixed to use get_all_users + else: + flash('Bitte melden Sie sich an, um auf diese Funktion zuzugreifen', 'error') + return redirect(url_for('login')) # Added proper return + +# New routes for filter management + +@app.route('/manage_filters') +def manage_filters(): + """ +" + Admin page to manage predefined filter values. + + Returns: + flask.Response: Rendered filter management template or redirect + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Get predefined filter values + filter1_values = it.get_predefined_filter_values(1) + filter2_values = it.get_predefined_filter_values(2) + + return render_template( + 'manage_filters.html', + filter1_values=filter1_values, + filter2_values=filter2_values, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED + ) + +@app.route('/add_filter_value/', methods=['POST']) +def add_filter_value(filter_num): + """ + Add a new predefined value to the specified filter. + + Args: + filter_num (int): Filter number (1 or 2) + + Returns: + flask.Response: Redirect to filter management page + """ + if 'username' not in session or not us.check_admin(session['username']): + return jsonify({'success': False, 'error': 'Not authorized'}), 403 + + value = sanitize_form_value(request.form.get('value')) + + if not value: + flash('Bitte geben Sie einen Wert ein', 'error') + return redirect(url_for('manage_filters')) + + # Add the value to the filter + success = it.add_predefined_filter_value(filter_num, value) + + if success: + flash(f'Wert "{value}" wurde zu Filter {filter_num} hinzugefügt', 'success') + else: + flash(f'Wert "{value}" existiert bereits in Filter {filter_num}', 'error') + + return redirect(url_for('manage_filters')) + +@app.route('/remove_filter_value//', methods=['POST']) +def remove_filter_value(filter_num, value): + """ + Remove a predefined value from the specified filter. + + Args: + filter_num (int): Filter number (1 or 2) + value (str): Value to remove + + Returns: + flask.Response: Redirect to filter management page + """ + if 'username' not in session or not us.check_admin(session['username']): + return jsonify({'success': False, 'error': 'Not authorized'}), 403 + + # Remove the value from the filter + success = it.remove_predefined_filter_value(filter_num, value) + + if success: + flash(f'Wert "{value}" wurde aus Filter {filter_num} entfernt', 'success') + else: + flash(f'Fehler beim Entfernen des Wertes "{value}" aus Filter {filter_num}', 'error') + + return redirect(url_for('manage_filters')) + +@app.route('/get_predefined_filter_values/') +def get_predefined_filter_values(filter_num): + """ + API endpoint to get predefined values for a specific filter. + + Args: + filter_num (int): Filter number (1 or 2) + + + Returns: + dict: Dictionary containing predefined filter values + """ + values = it.get_predefined_filter_values(filter_num) + return jsonify({'values': values}) + +@app.route('/search_word/') +def search_word(word): + """Search items by Titel (Name) and Beschreibung, case-insensitive. + + Returns: JSON with list of matching item IDs. + """ + try: + term = (word or "").strip() + if not term: + return jsonify({"success": True, "response": []}) + + term_lower = term.lower() + id_set = set() + for i in it.get_items(): + beschreibung = i.get("Beschreibung", "") + titel = i.get("Name", "") + # Normalize Beschreibung to string + try: + if isinstance(beschreibung, (list, tuple)): + text = " ".join([str(x) for x in beschreibung]) + else: + text = str(beschreibung) + except Exception: + text = "" + + # Normalize title + try: + title_text = str(titel) + except Exception: + title_text = "" + + if (term_lower in text.lower()) or (term_lower in title_text.lower()): + _id = i.get("_id") + if _id is not None: + id_set.add(str(_id)) + + return jsonify({"success": True, "response": list(id_set)}) + except Exception as e: + return jsonify({"success": False, "response": str(e)}) + +@app.route('/fetch_book_info/') +def fetch_book_info(isbn): + """ + API endpoint to fetch book information by ISBN using Google Books API + + Args: + isbn (str): ISBN to look up + + Returns: + dict: Book information or error message + """ + if 'username' not in session or not us.check_admin(session['username']): + return jsonify({"error": "Not authorized"}), 403 + + if not cfg.LIBRARY_MODULE_ENABLED: + return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403 + + try: + clean_isbn = normalize_and_validate_isbn(isbn) + if not clean_isbn: + return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400 + + # First source: Google Books + response = requests.get( + f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}", + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + if data.get('totalItems', 0) > 0 and data.get('items'): + book_info = data['items'][0].get('volumeInfo', {}) + sale_info = data['items'][0].get('saleInfo', {}) + + price = None + retail_price = sale_info.get('retailPrice', {}) + list_price = sale_info.get('listPrice', {}) + if retail_price and 'amount' in retail_price: + price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}" + elif list_price and 'amount' in list_price: + price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}" + + thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '') + if thumbnail: + thumbnail = thumbnail.replace('http:', 'https:') + + return jsonify({ + "title": book_info.get('title', 'Unknown Title'), + "authors": ', '.join(book_info.get('authors', ['Unknown Author'])), + "publisher": book_info.get('publisher', 'Unknown Publisher'), + "publishedDate": book_info.get('publishedDate', 'Unknown Date'), + "description": book_info.get('description', 'No description available'), + "pageCount": book_info.get('pageCount', 'Unknown'), + "price": price, + "thumbnail": thumbnail, + "isbn": clean_isbn, + "source": "google-books" + }) + + # Fallback: OpenLibrary + ol_response = requests.get( + f"https://openlibrary.org/isbn/{clean_isbn}.json", + timeout=10 + ) + if ol_response.status_code == 200: + ol_data = ol_response.json() + author_names = [] + for author_ref in ol_data.get('authors', []): + key = author_ref.get('key') + if not key: + continue + try: + author_resp = requests.get(f"https://openlibrary.org{key}.json", timeout=8) + if author_resp.status_code == 200: + author_names.append(author_resp.json().get('name')) + except Exception: + continue + + return jsonify({ + "title": ol_data.get('title', 'Unknown Title'), + "authors": ', '.join([a for a in author_names if a]) if author_names else 'Unknown Author', + "publisher": ', '.join(ol_data.get('publishers', [])) if ol_data.get('publishers') else 'Unknown Publisher', + "publishedDate": ol_data.get('publish_date', 'Unknown Date'), + "description": (ol_data.get('description', {}).get('value') if isinstance(ol_data.get('description'), dict) else ol_data.get('description')) or 'No description available', + "pageCount": ol_data.get('number_of_pages', 'Unknown'), + "price": None, + "thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg", + "isbn": clean_isbn, + "source": "openlibrary" + }) + + return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404 + + except Exception as e: + print(f"Error fetching book data: {e}") + return jsonify({"error": f"Failed to fetch book information: {str(e)}"}), 500 + +@app.route('/download_book_cover', methods=['POST']) +def download_book_cover(): + """ + API endpoint to download and save a book cover image from URL + + Returns: + dict: Success status and filename or error message + """ + if 'username' not in session: + return jsonify({"error": "Not authorized"}), 403 + if not us.check_admin(session['username']): + return jsonify({"error": "Admin privileges required"}), 403 + if not cfg.LIBRARY_MODULE_ENABLED: + return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403 + + try: + data = request.get_json() + image_url = data.get('url') + + if not image_url: + return jsonify({"error": "No image URL provided"}), 400 + + # Download the image + response = requests.get(image_url, stream=True, timeout=10) + + if response.status_code != 200: + return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400 + + # Check content type to ensure it's an image of allowed format + content_type = response.headers.get('content-type', '') + allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'] + + if not any(allowed_type in content_type.lower() for allowed_type in allowed_types): + return jsonify({ + "error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF" + }), 400 + + # Generate a fully unique filename using UUID + import uuid + import time + + unique_id = str(uuid.uuid4()) + timestamp = time.strftime("%Y%m%d%H%M%S") + + # Use appropriate extension based on content type + extension = '.jpg' # default + if 'image/png' in content_type.lower(): + extension = '.png' + elif 'image/gif' in content_type.lower(): + extension = '.gif' + + filename = f"book_cover_{unique_id}_{timestamp}{extension}" + + # Save the image to uploads folder + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + return jsonify({ + "success": True, + "filename": filename, + "message": "Image downloaded successfully" + }) + + except Exception as e: + print(f"Error downloading book cover: {e}") + + return jsonify({"error": f"Failed to download image: {str(e)}"}), 500 + +@app.route('/proxy_image') +def proxy_image(): + """ + Proxy endpoint to fetch images from external sources, + bypassing CORS restrictions + + Returns: + flask.Response: The image data or an error response + """ + url = request.args.get('url') + if not url: + return jsonify({"error": "No URL provided"}), 400 + + try: + # Fetch the image from the external source + response = requests.get(url, stream=True, timeout=5) + + # Check if the request was successful + if response.status_code != 200: + return jsonify({"error": f"Failed to fetch image: Status {response.status_code}"}), response.status_code + + # Get the content type + content_type = response.headers.get('Content-Type', 'image/jpeg') + + # Return the image data with appropriate headers + return Response( + response=response.content, + status=200, + headers={ + 'Content-Type': content_type + } + ) + except Exception as e: + print(f"Error in proxy_image: {e}") + return jsonify({"error": f"Error fetching image: {str(e)}"}), 500 + +# Add missing get_period_times function +def get_period_times(booking_date, period_num): + """ + Get the start and end times for a given period on a specific date + + Args: + booking_date (datetime): The date for the booking + period_num (int): The period number + + Returns: + dict: {"start": datetime, "end": datetime} or None if invalid + """ + try: + # Convert period_num to string for lookup in SCHOOL_PERIODS + period_str = str(period_num) + + if period_str not in SCHOOL_PERIODS: + return None + + period_info = SCHOOL_PERIODS[period_str] + + # Extract start and end times from period info + start_time_str = period_info.get('start') + end_time_str = period_info.get('end') + + if not start_time_str or not end_time_str: + return None + + # Parse hours and minutes + start_hour, start_min = map(int, start_time_str.split(':')) + end_hour, end_min = map(int, end_time_str.split(':')) + + # Create datetime objects for start and end times + start_datetime = datetime.datetime.combine( + booking_date.date(), + datetime.time(start_hour, start_min) + ) + + end_datetime = datetime.datetime.combine( + booking_date.date(), + datetime.time(end_hour, end_min) + ) + + return { + 'start': start_datetime, + 'end': end_datetime + } + except Exception as e: + print(f"Error getting period times: {e}") + return None + +@app.route('/my_borrowed_items') +def my_borrowed_items(): + """ + Zeigt alle vom aktuellen Benutzer ausgeliehenen und geplanten Objekte an. + + Returns: + Response: Gerendertes Template mit den ausgeliehenen und geplanten Objekten des Benutzers + """ + if 'username' not in session: + flash('Bitte melden Sie sich an, um Ihre ausgeliehenen Objekte anzuzeigen', 'error') + return redirect(url_for('login', next=request.path)) + + username = session['username'] + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_collection = db.items + ausleihungen_collection = db.ausleihungen + + # Get current time for comparison + current_time = datetime.datetime.now() + + # Check if user is admin + user_is_admin = False + if 'is_admin' in session: + user_is_admin = session['is_admin'] + + # Get items currently borrowed by the user (where Verfuegbar=false and User=username) + borrowed_items = list(items_collection.find({'Verfuegbar': False, 'User': username})) + + # Get active and planned ausleihungen for the user + active_ausleihungen = list(ausleihungen_collection.find({ + 'User': username, + 'Status': 'active' + })) + + planned_ausleihungen = list(ausleihungen_collection.find({ + 'User': username, + 'Status': 'planned', + 'Start': {'$gt': current_time} + })) + + # DEBUG: Log the number of planned appointments found + app.logger.info(f"Found {len(planned_ausleihungen)} planned appointments for user {username}") + for appt in planned_ausleihungen: + app.logger.info(f"Planned appointment: ID={str(appt['_id'])}, Item={str(appt.get('Item'))}, Start={appt.get('Start')}") + + # Process items + active_items = [] + planned_items = [] + processed_item_ids = set() # Keep track of processed item IDs to avoid duplicates + + # First, process items that are directly marked as borrowed by the user + for item in borrowed_items: + # Convert ObjectId to string for template + item['_id'] = str(item['_id']) + active_items.append(item) + processed_item_ids.add(item['_id']) + + # Process active appointments + for appointment in active_ausleihungen: + # Get the item ID from the appointment + item_id = appointment.get('Item') + + if not item_id or str(item_id) in processed_item_ids: + continue # Skip if we already processed this item or no item ID + + # Get item details + item_obj = items_collection.find_one({'_id': ObjectId(item_id)}) + + if item_obj: + # Convert ObjectId to string for template + item_obj['_id'] = str(item_obj['_id']) + + # Add appointment data + item_obj['AppointmentData'] = { + 'id': str(appointment['_id']), + 'start': appointment.get('Start'), + 'end': appointment.get('End'), + 'notes': appointment.get('Notes'), + 'period': appointment.get('Period'), + 'status': appointment.get('VerifiedStatus', appointment.get('Status')), + } + + # Mark that this item is part of an active appointment + item_obj['ActiveAppointment'] = True + + # Add to the list only if not already there + if str(item_obj['_id']) not in processed_item_ids: + active_items.append(item_obj) + processed_item_ids.add(str(item_obj['_id'])) + + # Process planned appointments + for appointment in planned_ausleihungen: + item_id = appointment.get('Item') + + if not item_id: + continue + + item_obj = items_collection.find_one({'_id': ObjectId(item_id)}) + + if item_obj: + item_obj['_id'] = str(item_obj['_id']) + + # Add appointment data + item_obj['AppointmentData'] = { + 'id': str(appointment['_id']), + 'start': appointment.get('Start'), + 'end': appointment.get('End'), + 'notes': appointment.get('Notes'), + 'period': appointment.get('Period'), + 'status': appointment.get('Status'), + } + + planned_items.append(item_obj) + + client.close() + + # DEBUG: Log what we're passing to the template + app.logger.info(f"Passing {len(active_items)} active items and {len(planned_items)} planned items to template") + if planned_items: + for i, item in enumerate(planned_items): + app.logger.info(f"Planned item {i+1}: {item['Name']}, Appointment ID: {item['AppointmentData']['id']}") + + return render_template( + 'my_borrowed_items.html', + items=active_items, + planned_items=planned_items, + user_is_admin=user_is_admin + ) + +@app.route('/favicon.ico') +def favicon(): + """ + Serve the favicon directly from the static directory. + + Returns: + flask.Response: The favicon.ico file + """ + return send_from_directory(app.static_folder, 'favicon.ico') + +@app.route('/get_predefined_locations') +def get_predefined_locations_route(): + """ + API endpoint to get predefined locations. + + Returns: + dict: Dictionary containing predefined location values + """ + values = it.get_predefined_locations() + return jsonify({'locations': values}) + +@app.route('/add_location_value', methods=['POST']) +def add_location_value(): + """ + Add a new predefined location value. + + Returns: + flask.Response: Redirect to location management page + """ + if 'username' not in session or not us.check_admin(session['username']): + return jsonify({'success': False, 'error': 'Not authorized'}), 403 + + value = sanitize_form_value(request.form.get('value')) + + if not value: + flash('Bitte geben Sie einen Wert ein', 'error') + return redirect(url_for('manage_locations')) + + # Add the value to locations + success = it.add_predefined_location(value) + + if success: + flash(f'Ort "{value}" wurde zur Liste hinzugefügt', 'success') + else: + flash(f'Ort "{value}" existiert bereits', 'error') + + return redirect(url_for('manage_locations')) + +@app.route('/remove_location_value/', methods=['POST']) +def remove_location_value(value): + """ + Remove a predefined location value. + + Args: + value (str): Value to remove + + Returns: + flask.Response: Redirect to location management page + """ + if 'username' not in session or not us.check_admin(session['username']): + return jsonify({'success': False, 'error': 'Not authorized'}), 403 + + # Remove the value from locations + success = it.remove_predefined_location(value) + + if success: + flash(f'Ort "{value}" wurde aus der Liste entfernt', 'success') + else: + flash(f'Fehler beim Entfernen des Ortes "{value}"', 'error') + + return redirect(url_for('manage_locations')) + +@app.route('/manage_locations') +def manage_locations(): + """ + Admin page to manage predefined location values. + + Returns: + flask.Response: Rendered location management template or redirect + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + if not us.check_admin(session['username']): + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + # Get predefined location values + location_values = it.get_predefined_locations() + + return render_template( + 'manage_locations.html', + location_values=location_values, + library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, + student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED + ) + +@app.route('/check_code_unique/') +def check_code_unique(code): + """ + API endpoint to check if a code is unique + + Args: + code (str): Code to check + exclude_id (str, optional): ID of item to exclude from check (for edit operations) + + Returns: + dict: JSON response with is_unique boolean + """ + exclude_id = request.args.get('exclude_id') + is_unique = it.is_code_unique(code, exclude_id) + + return jsonify({ + 'is_unique': is_unique, + 'code': code + }) + +@app.route('/schedule_appointment', methods=['POST']) +def schedule_appointment(): + """ + Schedule an appointment for an item + """ + if 'username' not in session: + return jsonify({'error': 'Nicht angemeldet'}), 401 + + try: + # Extract form data + item_id = request.form.get('item_id') + specific_item_id = (request.form.get('specific_item_id') or '').strip() + schedule_date = request.form.get('schedule_date') + start_period = request.form.get('start_period') + end_period = request.form.get('end_period') + notes = request.form.get('notes', '') + + # Check for multi-day + is_multi_day = request.form.get('is_multi_day') == 'on' + schedule_end_date = request.form.get('schedule_end_date') + + # Validate inputs + if not all([item_id, schedule_date, start_period, end_period]): + return jsonify({'success': False, 'message': 'Pflichtfelder fehlen'}), 400 + + # Parse the start date + try: + appointment_date_obj = datetime.datetime.strptime(schedule_date, '%Y-%m-%d') + appointment_date = appointment_date_obj.date() # Get date part only + except ValueError: + return jsonify({'success': False, 'message': 'Ungültiges Datumsformat'}), 400 + + # Parse end date if multi-day + appointment_end_date = appointment_date + if is_multi_day and schedule_end_date: + try: + appointment_end_date_obj = datetime.datetime.strptime(schedule_end_date, '%Y-%m-%d') + appointment_end_date = appointment_end_date_obj.date() + + if appointment_end_date < appointment_date: + return jsonify({'success': False, 'message': 'Enddatum kann nicht vor Startdatum liegen'}), 400 + except ValueError: + return jsonify({'success': False, 'message': 'Ungültiges Enddatumsformat'}), 400 + + # Validate periods + try: + start_period_num = int(start_period) + end_period_num = int(end_period) + + # Only check period order if it's the same day + if appointment_date == appointment_end_date and start_period_num > end_period_num: + return jsonify({'success': False, 'message': 'Startperiode kann nicht nach Endperiode liegen'}), 400 + + if not (1 <= start_period_num <= 10) or not (1 <= end_period_num <= 10): + return jsonify({'success': False, 'message': 'Ungültige Periodennummern'}), 400 + + except ValueError: + return jsonify({'success': False, 'message': 'Ungültige Periodenwerte'}), 400 + + # Check if item exists + item = it.get_item(item_id) + if not item: + return jsonify({'success': False, 'message': 'Element nicht gefunden'}), 404 + + # Grouped inventory mode: allow scheduling for a specific physical unit. + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + grouped_children = list(items_col.find({'ParentItemId': item_id, 'IsGroupedSubItem': True})) + client.close() + except Exception: + grouped_children = [] + + if grouped_children: + grouped_units = [item] + [{**child, '_id': str(child.get('_id'))} for child in grouped_children] + available_units = [unit for unit in grouped_units if unit.get('Verfuegbar', True)] + + chosen_unit = None + if specific_item_id: + chosen_unit = next((unit for unit in grouped_units if str(unit.get('_id')) == specific_item_id), None) + if not chosen_unit: + return jsonify({'success': False, 'message': 'Der gewählte Unterartikel wurde nicht gefunden.'}), 400 + else: + chosen_unit = available_units[0] if available_units else grouped_units[0] + + item_id = str(chosen_unit.get('_id')) + item = chosen_unit + + # Check if item is reservable + if not item.get('Reservierbar', True): + return jsonify({'success': False, 'message': 'Dieses Item kann nicht reserviert werden.'}), 400 + + # Calculate start and end times for the appointment + # Make sure we're passing the correct datetime object + period_start_datetime = datetime.datetime.combine(appointment_date, datetime.time()) + period_times_start = get_period_times(period_start_datetime, start_period_num) + + period_end_datetime = datetime.datetime.combine(appointment_end_date, datetime.time()) + period_times_end = get_period_times(period_end_datetime, end_period_num) + + # Check if we got valid period times + if not period_times_start or not period_times_end: + print(f"Invalid period times: start={period_times_start}, end={period_times_end}") + return jsonify({'success': False, 'message': 'Ungültige Periodenzeiten'}), 400 + + start_datetime = period_times_start['start'] + end_datetime = period_times_end['end'] + + # Determine if we should use period-based booking or time-based (multi-day) + # If it's multi-day (dates differ), we treat it as a continuous block (Period=None) + # If it's single day, we use the period range logic + + booking_period = None + booking_period_end = None + + if appointment_date == appointment_end_date: + booking_period = start_period_num + booking_period_end = end_period_num + + # Check for conflicts (use full period-range aware check) + try: + has_conflict = au.check_booking_period_range_conflict( + item_id, + start_datetime, + end_datetime, + period=booking_period, + period_end=booking_period_end + ) + if has_conflict: + return jsonify({'success': False, 'message': 'Termin kollidiert mit bestehender Buchung'}), 409 + except Exception as e: + print(f"Error checking for booking conflicts: {e}") + return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit: {str(e)}'}), 500 + + # Create the appointment as a planned booking + try: + appointment_id = au.add_planned_booking( + item_id=item_id, + user=session['username'], + start_date=start_datetime, + end_date=end_datetime, + notes=notes, + period=booking_period # Will be None for multi-day + ) + + if not appointment_id: + return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500 + except Exception as e: + print(f"Error creating planned booking: {e}") + return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins: {str(e)}'}), 500 + + # If we got this far, we have a valid appointment_id + try: + # Update item with next scheduled appointment info + # Convert date to datetime for MongoDB storage if needed + if isinstance(appointment_date, datetime.date) and not isinstance(appointment_date, datetime.datetime): + appointment_datetime = datetime.datetime.combine(appointment_date, datetime.time()) + else: + appointment_datetime = appointment_date + + # Handle end date conversion + if isinstance(appointment_end_date, datetime.date) and not isinstance(appointment_end_date, datetime.datetime): + appointment_end_datetime = datetime.datetime.combine(appointment_end_date, datetime.time()) + else: + appointment_end_datetime = appointment_end_date + + result = it.update_item_next_appointment(item_id, { + 'date': appointment_datetime, + 'end_date': appointment_end_datetime, + 'start_period': start_period_num, + 'end_period': end_period_num, + 'user': session['username'], + 'notes': notes, + 'appointment_id': str(appointment_id) + }) + + if result: + return jsonify({'success': True, 'appointment_id': str(appointment_id)}) + else: + print("Failed to update item with appointment info") + return jsonify({'success': False, 'message': 'Element konnte nicht mit Termininformationen aktualisiert werden'}), 500 + + except Exception as e: + print(f"Error updating item with appointment info: {e}") + return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements: {str(e)}'}), 500 + + except Exception as e: + print(f"Error creating appointment: {e}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'message': f'Serverfehler aufgetreten: {str(e)}'}), 500 + +@app.route('/cancel_ausleihung/', methods=['POST']) +def cancel_ausleihung_route(id): + """ + Route for canceling a planned or active ausleihung. + + Args: + id (str): ID of the ausleihung to cancel + + Returns: + flask.Response: Redirect to My Ausleihungen page + """ + if 'username' not in session: + flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') + return redirect(url_for('login')) + + username = session['username'] + + try: + print(f"Attempting to cancel ausleihung with ID: {id}") + + # Get the ausleihung record to check if it belongs to the current user + ausleihung = au.get_ausleihung(id) + + if not ausleihung: + print(f"Ausleihung not found with ID: {id}") + flash('Ausleihung nicht gefunden', 'error') + return redirect(url_for('my_borrowed_items')) + + # Log ausleihung details for debugging + ausleihung_status = ausleihung.get('Status', 'unknown') + ausleihung_user = ausleihung.get('User', 'unknown') + print(f"Found ausleihung: ID={id}, User={ausleihung_user}, Status={ausleihung_status}") + + # Check if the ausleihung belongs to the current user + if ausleihung_user != username and not us.check_admin(username): + print(f"Authorization failure: {username} attempted to cancel ausleihung belonging to {ausleihung_user}") + flash('Sie sind nicht berechtigt, diese Ausleihung zu stornieren', 'error') + return redirect(url_for('my_borrowed_items')) + + # Cancel the ausleihung + if au.cancel_ausleihung(id): + print(f"Successfully canceled ausleihung with ID: {id}") + flash('Ausleihung wurde erfolgreich storniert', 'success') + # Also clear NextAppointment on the related item if it matches this appointment + try: + item_id = str(ausleihung.get('Item')) if ausleihung.get('Item') is not None else None + if item_id: + item_doc = it.get_item(item_id) + if item_doc: + next_appt = item_doc.get('NextAppointment', {}) + if next_appt and str(next_appt.get('appointment_id')) == str(id): + cleared = it.clear_item_next_appointment(item_id) + print(f"Cleared NextAppointment for item {item_id}: {cleared}") + except Exception as clear_err: + print(f"Warning: could not clear NextAppointment for cancelled ausleihung {id}: {clear_err}") + else: + print(f"Failed to cancel ausleihung with ID: {id}") + flash('Fehler beim Stornieren der Ausleihung', 'error') + + except Exception as e: + print(f"Error canceling ausleihung: {e}") + flash(f'Fehler: {str(e)}', 'error') + + return redirect(url_for('my_borrowed_items')) + +@app.route('/reset_item/', methods=['POST']) +def reset_item(id): + """ + Route for completely resetting an item's borrowing status. + This handles items that have inconsistent borrowing states. + + Args: + id (str): ID of the item to reset + + Returns: + JSON: Success status and details + """ + if 'username' not in session: + return jsonify({'success': False, 'error': 'Not authenticated'}), 401 + + if not us.check_admin(session['username']): + return jsonify({'success': False, 'error': 'Not authorized'}), 403 + + try: + # Import the ausleihung module + import ausleihung as au + + result = au.reset_item_completely(id) + + if result['success']: + return jsonify({ + 'success': True, + 'message': result['message'], + 'details': result.get('details', {}) + }) + else: + return jsonify({ + 'success': False, + 'message': result['message'] + }), 400 + + except Exception as e: + print(f"Error in reset_item route: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': f'Serverfehler: {str(e)}' + }), 500 + +# New image and video optimization functions +def is_image_file(filename): + """ + Check if a file is an image based on its extension. + + Args: + filename (str): Name of the file to check + + Returns: + bool: True if the file is an image, False otherwise + """ + image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.svg'} + extension = filename.lower()[filename.rfind('.'):] + return extension in image_extensions + + +def is_video_file(filename): + """ + Check if a file is a video based on its extension. + + Args: + filename (str): Name of the file to check + + Returns: + bool: True if the file is a video, False otherwise + """ + video_extensions = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'} + extension = filename.lower()[filename.rfind('.'):] + return extension in video_extensions + + +def normalize_image_orientation(img, log_prefix=""): + """ + Normalize image orientation using EXIF metadata. + + Many phone images are stored as "rotated + EXIF orientation tag". + Converting them to other formats (e.g. WebP) without applying the EXIF + transform can make portrait images appear sideways. + """ + try: + return ImageOps.exif_transpose(img) + except Exception as exif_err: + if log_prefix: + app.logger.warning(f"{log_prefix} Could not apply EXIF orientation: {str(exif_err)}") + return img + + +def create_image_thumbnail(image_path, thumbnail_path, size, debug_prefix=""): + """ + Create a thumbnail for an image file, always converting to WebP format. + + Args: + image_path (str): Path to the original image + thumbnail_path (str): Path where the thumbnail should be saved + size (tuple): Thumbnail size as (width, height) + debug_prefix (str, optional): Prefix for debug logs + + Returns: + bool: True if thumbnail was created successfully, False otherwise + """ + # Check if this is a PNG file + is_png = image_path.lower().endswith('.png') + log_prefix = debug_prefix if debug_prefix else (f"PNG DEBUG: [{os.path.basename(image_path)}]" if is_png else "") + + try: + if is_png and log_prefix: + app.logger.info(f"{log_prefix} Creating thumbnail from PNG: {image_path} -> {thumbnail_path}") + + try: + with Image.open(image_path) as img: + img = normalize_image_orientation(img, log_prefix) + + if is_png and log_prefix: + app.logger.info(f"{log_prefix} PNG opened successfully: Format={img.format}, Mode={img.mode}, Size={img.size}") + + # Create thumbnail with proper aspect ratio + if is_png and log_prefix: + app.logger.info(f"{log_prefix} Resizing PNG to {size}") + try: + img.thumbnail(size, Image.Resampling.LANCZOS) + except Exception as resize_err: + if is_png and log_prefix: + app.logger.error(f"{log_prefix} Error during PNG resize: {str(resize_err)}") + app.logger.info(f"{log_prefix} Trying alternative resize method") + # Try alternative resize method + img = img.resize((min(img.width, size[0]), min(img.height, size[1])), Image.Resampling.BILINEAR) + + # Create a new image with the exact size (add padding if needed) + # Use RGBA for transparency support in WebP + thumb = Image.new('RGBA', size, (255, 255, 255, 0)) + + # Calculate position to center the image + x = (size[0] - img.size[0]) // 2 + y = (size[1] - img.size[1]) // 2 + + # Convert image to RGBA if it's not already + if img.mode != 'RGBA': + img = img.convert('RGBA') + + thumb.paste(img, (x, y), img) + + # Ensure the thumbnail path ends with .webp + if not thumbnail_path.lower().endswith('.webp'): + thumbnail_path = os.path.splitext(thumbnail_path)[0] + '.webp' + + # Ensure target directory exists + os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) + + # Save with optimization + thumb.save(thumbnail_path, 'WEBP', quality=85, method=6) + return True + except Exception as img_err: + # Special handling for corrupted PNGs + if is_png and log_prefix: + app.logger.error(f"{log_prefix} Error opening PNG with PIL: {str(img_err)}") + + # Try to fix the PNG if possible + app.logger.info(f"{log_prefix} Attempting to fix corrupt PNG") + try: + # Create a placeholder thumbnail since we can't process this PNG + thumb = Image.new('RGBA', size, (200, 200, 200, 255)) + # Add text indicating error + from PIL import ImageDraw + draw = ImageDraw.Draw(thumb) + text = "PNG Error" + draw.text((size[0]//4, size[1]//2), text, fill=(0, 0, 0, 255)) + # Continue with saving this placeholder + app.logger.info(f"{log_prefix} Created placeholder for corrupt PNG") + except Exception as fix_err: + app.logger.error(f"{log_prefix} Failed to create PNG placeholder: {str(fix_err)}") + raise img_err # Re-raise the original error if we couldn't create a placeholder + else: + # For non-PNG files, just propagate the error + raise + + if not thumbnail_path.lower().endswith('.webp'): + thumbnail_path = os.path.splitext(thumbnail_path)[0] + '.webp' + os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) + # Save with optimization + thumb.save(thumbnail_path, 'WEBP', quality=85, method=6) + return True + + except Exception as e: + print(f"Error creating image thumbnail for {image_path}: {str(e)}") + return False + + +def create_video_thumbnail(video_path, thumbnail_path, size): + """ + Create a thumbnail for a video file using ffmpeg. + + Args: + video_path (str): Path to the original video + thumbnail_path (str): Path where the thumbnail should be saved + size (tuple): Thumbnail size as (width, height) + + Returns: + bool: True if thumbnail was created successfully, False otherwise + """ + try: + # Use ffmpeg to extract a frame from the video (at 1 second) + cmd = [ + 'ffmpeg', + '-i', video_path, + '-ss', '00:00:01.000', # Extract frame at 1 second + '-vframes', '1', + '-y', # Overwrite output file + thumbnail_path + '.temp.jpg' + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0 and os.path.exists(thumbnail_path + '.temp.jpg'): + # Now resize the extracted frame using PIL + success = create_image_thumbnail(thumbnail_path + '.temp.jpg', thumbnail_path, size) + + # Clean up temporary file + try: + os.remove(thumbnail_path + '.temp.jpg') + except: + pass + + return success + else: + print(f"ffmpeg failed for {video_path}: {result.stderr}") + return False + + except Exception as e: + print(f"Error creating video thumbnail for {video_path}: {str(e)}") + return False + + +def generate_optimized_versions(filename, max_original_width=500, target_size_kb=80, debug_prefix=""): + """ + Generate optimized version of uploaded files. + Convert all image files to WebP format. + Also resizes and compresses the original image to save storage space. + No separate thumbnail or preview files are generated. + + Args: + filename (str): Name of the uploaded file + max_original_width (int): Maximum width for the original image (default: 500px) + target_size_kb (int): Target file size in kilobytes (default: 80KB) + + Returns: + dict: Dictionary with paths to generated files + """ + # Create a process ID for logging + process_id = str(uuid.uuid4())[:6] + log_prefix = f"[Optimize-{process_id}][{filename}]" + app.logger.info(f"{log_prefix} Starting optimization") + + # Make sure all required directories exist + for directory in [app.config['UPLOAD_FOLDER']]: + os.makedirs(directory, exist_ok=True) + + original_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + # Fallback to production path if dev path missing + if not os.path.exists(original_path): + prod_upload = "/var/Inventarsystem/Web/uploads" + alt_path = os.path.join(prod_upload, filename) + if os.path.exists(alt_path): + original_path = alt_path + + # Generate file paths + name_part, ext = os.path.splitext(filename) + ext = ext.lower() + is_webp_ext = ext == '.webp' + + # If already a WebP, keep filename to avoid same-file writes + converted_filename = filename if is_webp_ext else f"{name_part}.webp" + converted_path = os.path.join(app.config['UPLOAD_FOLDER'], converted_filename) + + result = { + 'original': converted_filename, # Use WebP name; if already WebP, this equals input + 'thumbnail': None, + 'preview': None, + 'is_image': False, + 'is_video': False, + 'success': False + } + + # Check if the file actually exists + if not os.path.exists(original_path): + app.logger.error(f"{log_prefix} Original file not found: {original_path}") + + # Check if we need to use a placeholder + placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') + if not os.path.exists(placeholder_path): + placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') + # Also check production static dir + if not os.path.exists(placeholder_path): + prod_static = "/var/Inventarsystem/Web/static/img" + fallback_svg = os.path.join(prod_static, 'no-image.svg') + fallback_png = os.path.join(prod_static, 'no-image.png') + if os.path.exists(fallback_svg): + placeholder_path = fallback_svg + elif os.path.exists(fallback_png): + placeholder_path = fallback_png + + if os.path.exists(placeholder_path): + app.logger.info(f"{log_prefix} Using placeholder image instead") + try: + # Copy placeholder to uploads folder with the original filename + shutil.copy2(placeholder_path, original_path) + result['original'] = filename + result['is_placeholder'] = True + result['success'] = True + return result + except Exception as e: + app.logger.error(f"{log_prefix} Failed to use placeholder: {str(e)}") + return result + else: + app.logger.error(f"{log_prefix} No placeholder found, cannot continue") + return result + + # Check if it's an image or video file + is_png = filename.lower().endswith('.png') + + if is_image_file(filename): + result['is_image'] = True + app.logger.info(f"{log_prefix} Processing as image file") + + # Special logging for PNG files + if is_png: + if debug_prefix: + app.logger.info(f"{debug_prefix} Processing PNG in optimization function") + else: + app.logger.info(f"PNG DEBUG: {log_prefix} Processing PNG in optimization function") + elif is_video_file(filename): + result['is_video'] = True + app.logger.info(f"{log_prefix} Processing as video file") + # For videos, we might want to extract a frame as the "image" representation + # But for now, we'll just leave it as is, assuming the video itself is the asset + # If a thumbnail is needed for video, we might need to generate one "poster" image + # But the requirement says "only the downsized Image", which implies for images. + # For videos, we'll just return success. + result['success'] = True + return result + else: + app.logger.info(f"{log_prefix} Not an image or video file, skipping optimization") + return result + + try: + # Get file info before processing + original_size = os.path.getsize(original_path) + app.logger.info(f"{log_prefix} Original size: {original_size/1024:.1f}KB") + + # Try to open and process the image + try: + with Image.open(original_path) as img: + img = normalize_image_orientation(img, log_prefix) + + # Special handling for PNG + is_png = filename.lower().endswith('.png') + if is_png: + debug_msg = debug_prefix if debug_prefix else f"PNG DEBUG: {log_prefix}" + app.logger.info(f"{debug_msg} Processing PNG image in optimization function") + app.logger.info(f"{debug_msg} PNG details - Format: {img.format}, Mode: {img.mode}") + + # Log original dimensions + original_width, original_height = img.size + app.logger.info(f"{log_prefix} Original dimensions: {original_width}x{original_height}") + + # Resize if needed + resized = False + if original_width > max_original_width: + try: + scaling_factor = max_original_width / original_width + new_width = max_original_width + new_height = int(original_height * scaling_factor) + # Resize with high quality resampling + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + app.logger.info(f"{log_prefix} Resized to {new_width}x{new_height}") + if is_png: + app.logger.info(f"{debug_msg} PNG resized to {new_width}x{new_height}") + resized = True + except Exception as e: + app.logger.error(f"{log_prefix} Resize failed: {str(e)}") + if is_png: + app.logger.error(f"{debug_msg} PNG resize failed: {str(e)}") + app.logger.error(f"{debug_msg} Error type: {type(e).__name__}") + # Continue without resizing + + # Save as WebP with compression to target file size + try: + # Get optimal quality setting to reach target size + # For WebP we can use a fixed quality or adapt it + quality = 80 # Default quality for WebP + app.logger.info(f"{log_prefix} Using quality setting: {quality}") + + # Standard save for WebP + if not is_webp_ext: + # Only create a new WebP if source wasn't already WebP + img.save(converted_path, 'WEBP', quality=quality, method=6) + app.logger.info(f"{log_prefix} Saved optimized WebP: {converted_path}") + + # Remove the original non-WebP file after successful conversion + if os.path.exists(converted_path): + try: + os.remove(original_path) + app.logger.info(f"{log_prefix} Removed original file after conversion") + except Exception as e: + app.logger.warning(f"{log_prefix} Error removing original file: {str(e)}") + else: + # Already a WebP: don't overwrite original; we'll use it for thumbs + app.logger.info(f"{log_prefix} Original is already WebP; skip in-place re-save") + + except Exception as save_err: + app.logger.error(f"{log_prefix} Failed to save optimized WebP: {str(save_err)}") + + # Try with default quality as fallback (only when not already WebP) + try: + if not is_webp_ext: + app.logger.info(f"{log_prefix} Attempting save with default quality") + img.save(converted_path, 'WEBP', quality=80, method=6) + app.logger.info(f"{log_prefix} Saved WebP with default quality") + else: + app.logger.info(f"{log_prefix} Skipping fallback save; original is WebP and won't be overwritten") + except Exception as default_save_err: + app.logger.error(f"{log_prefix} WebP fallback save also failed: {str(default_save_err)}") + + # If WebP conversion fails entirely and different path, copy original + if not is_webp_ext and os.path.abspath(original_path) != os.path.abspath(converted_path): + shutil.copy2(original_path, converted_path) + app.logger.warning(f"{log_prefix} Used original file without optimization") + + # Compare file sizes + if not is_webp_ext and os.path.exists(converted_path): + new_size = os.path.getsize(converted_path) + reduction = (1 - (new_size / original_size)) * 100 if original_size > 0 else 0 + app.logger.info(f"{log_prefix} Size reduction: {original_size/1024:.1f}KB -> {new_size/1024:.1f}KB ({reduction:.1f}%)") + + # Remove the original non-WebP file if it was converted or resized + if not is_webp_ext and os.path.exists(converted_path) and (not filename.lower().endswith('.webp') or resized): + try: + os.remove(original_path) + app.logger.info(f"{log_prefix} Removed original file after conversion") + except Exception as e: + app.logger.warning(f"{log_prefix} Error removing original file: {str(e)}") + + except Exception as e: + app.logger.error(f"{log_prefix} Compression error: {str(e)}") + # Use original file if optimization fails + if not os.path.exists(converted_path): + shutil.copy2(original_path, converted_path) + app.logger.warning(f"{log_prefix} Used original file as fallback") + + # Use the converted file for thumbnails if it exists + # If we produced a converted WebP (non-WebP source), use it as the basis for thumbs + if not is_webp_ext and os.path.exists(converted_path): + original_path = converted_path + + except Exception as e: + app.logger.error(f"{log_prefix} Failed to process image: {str(e)}") + traceback.print_exc() + # Just copy the original file as is + if not is_webp_ext and os.path.exists(original_path) and not os.path.exists(converted_path): + try: + shutil.copy2(original_path, converted_path) + app.logger.warning(f"{log_prefix} Used original file after processing error") + except Exception as copy_err: + app.logger.error(f"{log_prefix} Failed to copy original file: {str(copy_err)}") + + # Mark success if we have at least the original or converted file + if os.path.exists(original_path) or os.path.exists(converted_path): + result['success'] = True + app.logger.info(f"{log_prefix} Optimization completed successfully") + + # Log verification of all created files + for file_type, file_path in [ + ('Original', original_path), + ('Converted', converted_path) + ]: + if os.path.exists(file_path): + file_size = os.path.getsize(file_path) / 1024.0 # KB + app.logger.info(f"{log_prefix} {file_type}: {os.path.basename(file_path)} ({file_size:.1f}KB)") + else: + app.logger.warning(f"{log_prefix} {file_type} file missing: {os.path.basename(file_path)}") + + return result + + except Exception as e: + app.logger.error(f"{log_prefix} Unhandled exception in optimization: {str(e)}") + traceback.print_exc() + + # If anything went wrong but the original file exists, just use it + if os.path.exists(original_path): + try: + # Copy original to all required outputs as last resort + for target_path in [converted_path]: + if not os.path.exists(os.path.dirname(target_path)): + os.makedirs(os.path.dirname(target_path), exist_ok=True) + shutil.copy2(original_path, target_path) + + result['original'] = filename + result['success'] = True + result['recovery'] = True + app.logger.warning(f"{log_prefix} Recovery completed: using original file for all outputs") + return result + except Exception as recovery_err: + app.logger.error(f"{log_prefix} Recovery failed: {str(recovery_err)}") + + return result + +def get_thumbnail_info(filename): + """ + Get thumbnail and preview information for a file. + Returns the main image URL for all requests as we only use one image version now. + + Args: + filename (str): Original filename + + Returns: + dict: Dictionary with thumbnail and preview information + """ + if not filename: + return {'has_thumbnail': False, 'has_preview': False} + + name_part, ext_part = os.path.splitext(filename) + + # Check if the file exists (either as WebP or original extension) + # We prefer WebP if available + webp_filename = f"{name_part}.webp" + webp_path = os.path.join(app.config['UPLOAD_FOLDER'], webp_filename) + + original_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + + final_filename = filename + has_image = False + + if os.path.exists(webp_path): + final_filename = webp_filename + has_image = True + elif os.path.exists(original_path): + final_filename = filename + has_image = True + else: + # Check production paths + prod_upload = "/var/Inventarsystem/Web/uploads" + if os.path.exists(os.path.join(prod_upload, webp_filename)): + final_filename = webp_filename + has_image = True + elif os.path.exists(os.path.join(prod_upload, filename)): + final_filename = filename + has_image = True + + # Build URLs based on the filename + # We use the same URL for thumbnail and preview since we only have one image + image_url = f"/uploads/{final_filename}" if has_image else None + + # Backward compatibility: Check for legacy thumbnails/previews + thumbnail_url = image_url + has_thumbnail = has_image + + # Check for legacy thumbnail files if we have a main image or not + legacy_thumb_names = [ + f"{name_part}_thumb.webp", + f"{name_part}_thumb.jpg", + f"{name_part}_thumb{ext_part}" + ] + + for thumb_name in legacy_thumb_names: + if os.path.exists(os.path.join(app.config['THUMBNAIL_FOLDER'], thumb_name)): + thumbnail_url = f"/thumbnails/{thumb_name}" + has_thumbnail = True + break + elif os.path.exists(os.path.join("/var/Inventarsystem/Web/thumbnails", thumb_name)): + thumbnail_url = f"/thumbnails/{thumb_name}" + has_thumbnail = True + break + + # Check for legacy preview files + preview_url = image_url + has_preview = has_image + + legacy_preview_names = [ + f"{name_part}_preview.webp", + f"{name_part}_preview.jpg", + f"{name_part}_preview{ext_part}" + ] + + for preview_name in legacy_preview_names: + if os.path.exists(os.path.join(app.config['PREVIEW_FOLDER'], preview_name)): + preview_url = f"/previews/{preview_name}" + has_preview = True + break + elif os.path.exists(os.path.join("/var/Inventarsystem/Web/previews", preview_name)): + preview_url = f"/previews/{preview_name}" + has_preview = True + break + + return { + 'has_thumbnail': has_thumbnail, + 'has_preview': has_preview, + 'thumbnail_url': thumbnail_url, + 'preview_url': preview_url, + 'original_ext': os.path.splitext(final_filename)[1].lower(), + 'is_image': is_image_file(final_filename), + 'is_video': is_video_file(final_filename) + } + +# Mobile device detection utilities +def is_mobile_device(request): + """Determine if the request is coming from a mobile device""" + user_agent = request.headers.get('User-Agent', '').lower() + mobile_identifiers = ['iphone', 'ipad', 'android', 'mobile', 'tablet'] + return any(identifier in user_agent for identifier in mobile_identifiers) + +def is_ios_device(request): + """Determine if the request is coming from an iOS device""" + user_agent = request.headers.get('User-Agent', '').lower() + return 'iphone' in user_agent or 'ipad' in user_agent or 'ipod' in user_agent + +def log_mobile_action(action, request, success=True, details=None): + """Log mobile-specific actions for debugging""" + device_info = request.headers.get('User-Agent', 'Unknown device') + status = "SUCCESS" if success else "FAILED" + message = f"MOBILE {action} {status} - Device: {device_info}" + if details: + message += f" - Details: {details}" + + if success: + app.logger.info(message) + else: + app.logger.error(message) + +# Add explicit static file routes to handle CSS serving issues +@app.route('/static/') +def serve_static(filename): + """ + Explicitly serve static files to resolve 403 Forbidden errors. + This ensures CSS and JS files are properly accessible. + + Args: + filename (str): The static file path + + Returns: + flask.Response: The requested static file + """ + return send_from_directory(app.static_folder, filename) + +@app.route('/static/css/') +def serve_css(filename): + """ + Explicitly serve CSS files from the static/css directory. + + Args: + filename (str): Name of the CSS file to serve + + Returns: + flask.Response: The requested CSS file + """ + css_folder = os.path.join(app.static_folder, 'css') + return send_from_directory(css_folder, filename) + +@app.route('/static/js/') +def serve_js(filename): + """ + Explicitly serve JavaScript files from the static/js directory. + + Args: + filename (str): Name of the JS file to serve + + Returns: + flask.Response: The requested JS file + """ + js_folder = os.path.join(app.static_folder, 'js') + return send_from_directory(js_folder, filename) + +@app.route('/log_mobile_issue', methods=['POST']) +def log_mobile_issue(): + """ + Route for logging mobile-specific issues. + Used for tracking and debugging mobile browser problems. + + Returns: + flask.Response: JSON response with success status + """ + try: + # Get issue data from request + issue_data = request.json + + # Add timestamp if not present + if 'timestamp' not in issue_data: + issue_data['timestamp'] = datetime.now().isoformat() + + # Format the log message + log_message = f"MOBILE ISSUE: {issue_data.get('action', 'unknown')} - " + log_message += f"Error: {issue_data.get('error', 'none')} - " + log_message += f"Browser: {issue_data.get('browser', 'unknown')}" + + # Create a structured log entry + log_entry = { + 'type': 'mobile_issue', + 'timestamp': issue_data.get('timestamp'), + 'data': issue_data + } + + # Log to application log file + app.logger.warning(log_message) + + # Store in database for analytics + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + logs_collection = db['system_logs'] + logs_collection.insert_one(log_entry) + client.close() + + return jsonify({'success': True}) + except Exception as e: + app.logger.error(f"Error logging mobile issue: {str(e)}") + return jsonify({'success': False, 'error': str(e)}) + +def delete_item_images(filenames): + """ + Delete all images associated with an item. + Only deletes the main image file as we no longer use separate thumbnails/previews. + + Args: + filenames (list): List of image filenames to delete + + Returns: + dict: Statistics of deleted files + """ + stats = { + 'originals': 0, + 'thumbnails': 0, + 'previews': 0, + 'errors': 0 + } + + if not filenames: + return stats + + for filename in filenames: + if not filename: + continue + + try: + # Generate paths based on filename pattern + name_part = os.path.splitext(filename)[0] + + # Potential original files (WebP or JPG or original extension) + files_to_check = [ + (os.path.join(app.config['UPLOAD_FOLDER'], f"{name_part}.webp"), 'originals'), + (os.path.join(app.config['UPLOAD_FOLDER'], f"{name_part}.jpg"), 'originals'), + (os.path.join(app.config['UPLOAD_FOLDER'], filename), 'originals'), + + # Also try to clean up legacy thumbnails/previews if they exist + (os.path.join(app.config['THUMBNAIL_FOLDER'], f"{name_part}_thumb.webp"), 'thumbnails'), + (os.path.join(app.config['THUMBNAIL_FOLDER'], f"{name_part}_thumb.jpg"), 'thumbnails'), + (os.path.join(app.config['PREVIEW_FOLDER'], f"{name_part}_preview.webp"), 'previews'), + (os.path.join(app.config['PREVIEW_FOLDER'], f"{name_part}_preview.jpg"), 'previews') + ] + + # Delete all found files + for file_path, category in files_to_check: + if os.path.exists(file_path): + try: + os.remove(file_path) + stats[category] += 1 + except Exception as del_err: + app.logger.error(f"Failed to delete {file_path}: {del_err}") + stats['errors'] += 1 + + except Exception as e: + app.logger.error(f"Error deleting image files for {filename}: {str(e)}") + stats['errors'] += 1 + + return stats + +def get_optimal_image_quality(img, target_size_kb=80): + """ + Find the optimal JPEG quality setting to achieve a target file size. + Uses a binary search approach to efficiently find the best quality. + + Args: + img (PIL.Image): The PIL Image object + target_size_kb (int): Target file size in kilobytes + + Returns: + int: Quality setting (1-95) + """ + import io + + # Initialize search range + min_quality = 30 # We don't want to go lower than this + max_quality = 95 # No need to go higher than this + best_quality = 80 # Default quality + best_diff = float('inf') + target_size_bytes = target_size_kb * 1024 + + # Binary search for optimal quality + for _ in range(5): # 5 iterations is usually enough + quality = (min_quality + max_quality) // 2 + buffer = io.BytesIO() + img.save(buffer, format='JPEG', quality=quality, optimize=True) + size = buffer.tell() + + # Check how close we are to target size + diff = abs(size - target_size_bytes) + if diff < best_diff: + best_diff = diff + best_quality = quality + + # Adjust search range + if size > target_size_bytes: + max_quality = quality - 1 + else: + min_quality = quality + 1 + + # If we’re within 10% of target, that’s good enough + if abs(size - target_size_bytes) < (target_size_bytes * 0.1): + return quality + + return best_quality diff --git a/Web/ausleihung.py b/Web/ausleihung.py new file mode 100755 index 0000000..a9a5df6 --- /dev/null +++ b/Web/ausleihung.py @@ -0,0 +1,1226 @@ +""" +Ausleihungssystem (Borrowing System) +==================================== + +Dieses Modul verwaltet sämtliche Ausleihungen im Inventarsystem. +Es bietet alle Funktionen, um Ausleihungen zu planen, zu aktivieren, +zu beenden und zu stornieren. + +Hauptfunktionen: +- Erstellen neuer Ausleihungen (geplant oder sofort aktiv) +- Aktualisieren von Ausleihungsdaten +- Abschließen von Ausleihungen (Rückgabe) +- Suchen und Abrufen von Ausleihungen nach verschiedenen Kriterien +- Verwaltung des Ausleihungs-Lebenszyklus + +Sammlungsstruktur: +- ausleihungen: Speichert alle Ausleihungsdatensätze mit ihrem Status + - Status-Werte: 'planned' (geplant), 'active' (aktiv), 'completed' (abgeschlossen), 'cancelled' (storniert) +""" +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +from pymongo import MongoClient +from bson.objectid import ObjectId +import datetime +import pytz +from datetime import timezone +import os +import json +import shutil +import settings as cfg + +# Add this helper function after imports +def ensure_timezone_aware(dt): + """Ensures a datetime is timezone-aware, using UTC if naive""" + if dt is None: + return None + if dt.tzinfo is None: + # Treat naive datetimes as UTC + return dt.replace(tzinfo=None) + return dt + +def get_current_status(ausleihung, log_changes=False, user=None): + """ + Ermittelt den aktuellen Status einer Ausleihung basierend auf den Zeitstempeln + und dem gespeicherten Status. Diese Funktion berücksichtigt das aktuelle Datum + und aktualisiert den Status entsprechend dem realen Zustand. + + Status-Werte: + - 'planned': Eine zukünftige Ausleihung, die noch nicht begonnen hat + - 'active': Eine aktive Ausleihung, die gerade läuft + - 'completed': Eine beendete Ausleihung + - 'cancelled': Eine stornierte Ausleihung + + Args: + ausleihung (dict): Der Ausleihungsdatensatz + log_changes (bool): Ob Statusänderungen protokolliert werden sollen + user (str): Der Benutzer, der die Prüfung durchführt (für Logs) + + Returns: + str: Der aktuelle Status ('planned', 'active', 'completed', 'cancelled') + """ + # Speichern Sie den ursprünglichen Status für Logging-Zwecke + original_status = ausleihung.get('Status', 'unknown') + + # Bei stornierten Ausleihungen bleibt der Status immer storniert + if original_status == 'cancelled': + return 'cancelled' + + current_time = datetime.datetime.now() + start_time = ausleihung.get('Start') + end_time = ausleihung.get('End') + + # 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: + new_status = 'planned' + # Wenn kein Enddatum gesetzt ist oder die aktuelle Zeit vor dem Enddatum liegt, + # ist die Ausleihung aktiv + elif not end_time or current_time < end_time: + new_status = 'active' + # Wenn die aktuelle Zeit nach dem Enddatum liegt, ist die Ausleihung beendet + else: + new_status = 'completed' + + # Protokollieren Sie Statusänderungen, wenn aktiviert und eine Änderung stattgefunden hat + if log_changes and new_status != original_status and '_id' in ausleihung: + try: + # Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden + import ausleihung_log + ausleihung_log.log_status_change( + str(ausleihung['_id']), + original_status, + new_status, + user + ) + except Exception as e: + print(f"Fehler beim Protokollieren der Statusänderung: {e}") + + return new_status + +def create_backup_database(): + """ + Erstellt eine Sicherungskopie der Ausleihungsdatenbank. + + Returns: + bool: True wenn Backup erfolgreich erstellt wurde, sonst False + """ + try: + # Verbindung zur Datenbank herstellen + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Backup-Verzeichnis erstellen, falls es nicht existiert + backup_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'backups') + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + + # Aktuelles Datum für den Dateinamen + current_date = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') + backup_file = os.path.join(backup_dir, f'ausleihungen_backup_{current_date}.json') + + # Ausleihungen abrufen und als JSON speichern + all_ausleihungen = list(ausleihungen.find({})) + + # ObjectId in String umwandeln für JSON-Serialisierung + for ausleihung in all_ausleihungen: + ausleihung['_id'] = str(ausleihung['_id']) + if 'Start' in ausleihung and ausleihung['Start']: + ausleihung['Start'] = ausleihung['Start'].isoformat() + if 'End' in ausleihung and ausleihung['End']: + ausleihung['End'] = ausleihung['End'].isoformat() + if 'LastUpdated' in ausleihung and ausleihung['LastUpdated']: + ausleihung['LastUpdated'] = ausleihung['LastUpdated'].isoformat() + + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(all_ausleihungen, f, ensure_ascii=False, indent=4) + + # Log-Eintrag erstellen + log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs') + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + log_file = os.path.join(log_dir, 'ausleihungen_backup.log') + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"{current_date}: Backup erstellt: {backup_file}, {len(all_ausleihungen)} Einträge\n") + + client.close() + return True + except Exception as e: + # Fehler protokollieren + log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs') + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + log_file = os.path.join(log_dir, 'ausleihungen_error.log') + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}: Backup-Fehler: {str(e)}\n") + + print(f"Fehler beim Erstellen des Backups: {e}") + return False + + +# === AUSLEIHUNG MANAGEMENT === + +def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None): + """ + Add a new borrowing record for an item. + + Args: + item_id (str): ID of the item borrowed + user (str): Username of the borrower + start_date (datetime): Start date and time of the borrowing + end_date (datetime, optional): End date and time if already returned + notes (str, optional): Additional notes about the borrowing + status (str, optional): Status of the borrowing (active, completed, planned) + period (str, optional): School period for the borrowing + exemplar_data (dict, optional): Information about specific exemplar borrowed + + Returns: + ObjectId: ID of the new borrowing record or None if failed + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + ausleihung = { + 'Item': item_id, + 'User': user, + 'Start': start_date, + 'Status': status + } + + if end_date: + ausleihung['End'] = end_date + + if notes: + ausleihung['Notes'] = notes + + if period: + ausleihung['Period'] = period + + if exemplar_data: + ausleihung['ExemplarData'] = exemplar_data + + result = ausleihungen.insert_one(ausleihung) + ausleihung_id = result.inserted_id + + client.close() + return ausleihung_id + except Exception as e: + print(f"Error adding ausleihung: {e}") + return None + +def update_ausleihung(id, item_id=None, user_id=None, start=None, end=None, notes=None, status=None, period=None): + """ + Update an existing ausleihung record. + + Args: + id (str): ID of the ausleihung to update + item_id (str, optional): New item ID + user_id (str, optional): New user ID + start (datetime, optional): New start time + end (datetime, optional): New end time + notes (str, optional): New notes + status (str, optional): New status + period (int, optional): New period + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Build update data with only the fields that are provided + update_data = {} + + if item_id is not None: + update_data['Item'] = item_id + if user_id is not None: + update_data['User'] = user_id + if start is not None: + # Ensure timezone-aware datetime + update_data['Start'] = ensure_timezone_aware(start) + if end is not None: + # Ensure timezone-aware datetime + update_data['End'] = ensure_timezone_aware(end) + if notes is not None: + update_data['Notes'] = notes + if status is not None: + update_data['Status'] = status + if period is not None: + update_data['Period'] = period + + # Always update the LastUpdated timestamp + update_data['LastUpdated'] = datetime.datetime.now() + + # Perform the update + result = ausleihungen.update_one( + {'_id': ObjectId(id)}, + {'$set': update_data} + ) + + client.close() + + # Log the update for debugging + print(f"Updated ausleihung {id}: modified_count={result.modified_count}, update_data={update_data}") + + return result.modified_count > 0 + + except Exception as e: + print(f"Error updating ausleihung: {e}") + return False + +def complete_ausleihung(id, end_time=None): + """ + Markiert eine Ausleihe als abgeschlossen, indem das Enddatum gesetzt + und der Status auf 'completed' geändert wird. + + Args: + id (str): ID des abzuschließenden Ausleihungsdatensatzes + end_time (datetime, optional): Endzeitpunkt (Standard: aktuelle Zeit) + + Returns: + bool: True bei Erfolg, sonst False + """ + try: + if end_time is None: + end_time = datetime.datetime.now() + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + item = db['items'] + + result = ausleihungen.update_one( + {'_id': ObjectId(id)}, + {'$set': { + 'End': end_time, + 'Status': 'completed', + 'LastUpdated': datetime.datetime.now() + }} + ) + + item.update_one( + {'_id': ObjectId(id)}, + {'$set': { + 'Verfuegbar': True, + 'LastUpdated': datetime.datetime.now() + }} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + # print(f"Error completing ausleihung: {e}") # Log the error + return False + + +def cancel_ausleihung(id): + """ + Storniert eine geplante Ausleihe durch Änderung des Status auf 'cancelled'. + + Args: + id (str): ID der zu stornierenden Ausleihe + + Returns: + bool: True bei Erfolg, sonst False + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Mark the booking as cancelled + result = ausleihungen.update_one( + {'_id': ObjectId(id)}, + {'$set': { + 'Status': 'cancelled', + 'LastUpdated': datetime.datetime.now() + }} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + # print(f"Error cancelling ausleihung: {e}") # Log the error + return False + + +def remove_ausleihung(id): + """ + Entfernt einen Ausleihungsdatensatz aus der Datenbank. + Hinweis: Normalerweise ist es besser, Datensätze zu markieren als sie zu löschen. + + Args: + id (str): ID des zu entfernenden Ausleihungsdatensatzes + + Returns: + bool: True bei Erfolg, sonst False + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + result = ausleihungen.delete_one({'_id': ObjectId(id)}) + client.close() + return result.deleted_count > 0 + except Exception as e: + # print(f"Error removing ausleihung: {e}") # Log the error + return False + + +# === AUSLEIHUNG RETRIEVAL === + +def get_ausleihung(id): + """ + Ruft einen bestimmten Ausleihungsdatensatz anhand seiner ID ab. + + Args: + id (str): ID des abzurufenden Ausleihungsdatensatzes + + Returns: + dict: Der Ausleihungsdatensatz oder None, wenn nicht gefunden + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + ausleihung = ausleihungen.find_one({'_id': ObjectId(id)}) + client.close() + return ausleihung + except Exception as e: + # print(f"Error retrieving ausleihung: {e}") # Log the error + return None + + +def get_ausleihungen(status=None, start=None, end=None, date_filter='overlap'): + """ + Ruft Ausleihungen nach verschiedenen Kriterien ab. + + Args: + status (str/list, optional): Status(se) der Ausleihungen ('planned', 'active', 'completed', 'cancelled') + start (str/datetime, optional): Startdatum für Datumsfilterung + end (str/datetime, optional): Enddatum für Datumsfilterung + date_filter (str, optional): Art des Datumsfilters ('overlap', 'start_in', 'end_in', 'contained') + + Returns: + list: Liste von Ausleihungsdatensätzen + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + collection = db['ausleihungen'] + + # Query erstellen + query = {} + + # Status-Filter hinzufügen + if status is not None: + if isinstance(status, list): + query['Status'] = {'$in': status} + else: + query['Status'] = status + + # Datum parsen, wenn als String angegeben + if start is not None and isinstance(start, str): + try: + from dateutil import parser + start = parser.parse(start) + except: + start = None + + if end is not None and isinstance(end, str): + try: + from dateutil import parser + end = parser.parse(end) + except: + end = None + + # Datumsfilter hinzufügen + if start is not None and end is not None: + if date_filter == 'overlap': + # Überlappende Ausleihungen (Standard) + query['$or'] = [ + # Ausleihe beginnt im Bereich + {'Start': {'$gte': start, '$lte': end}}, + # Ausleihe endet im Bereich + {'End': {'$gte': start, '$lte': end}}, + # Ausleihe umfasst den gesamten Bereich + {'Start': {'$lte': start}, 'End': {'$gte': end}}, + # Aktive Ausleihungen ohne Ende, die vor dem Ende beginnen + {'Start': {'$lte': end}, 'End': None} + ] + elif date_filter == 'start_in': + # Nur Ausleihungen, die im Bereich beginnen + query['Start'] = {'$gte': start, '$lte': end} + elif date_filter == 'end_in': + # Nur Ausleihungen, die im Bereich enden + query['End'] = {'$gte': start, '$lte': end} + elif date_filter == 'contained': + # Nur Ausleihungen, die vollständig im Bereich liegen + query['Start'] = {'$gte': start} + query['End'] = {'$lte': end} + + results = list(collection.find(query)) + client.close() + return results + except Exception as e: + # print(f"Error retrieving ausleihungen: {e}") # Log the error + return [] + + +def get_active_ausleihungen(start=None, end=None): + """ + Ruft alle aktiven (laufenden) Ausleihungen ab. + + Args: + start (str/datetime, optional): Startdatum für Datumsfilterung + end (str/datetime, optional): Enddatum für Datumsfilterung + + Returns: + list: Liste aktiver Ausleihungsdatensätze + """ + return get_ausleihungen(status='active', start=start, end=end) + + +def get_planned_ausleihungen(start=None, end=None): + """ + Ruft alle geplanten Ausleihungen (Reservierungen) ab. + + Args: + start (str/datetime, optional): Startdatum für Datumsfilterung + end (str/datetime, optional): Enddatum für Datumsfilterung + + Returns: + list: Liste geplanter Ausleihungsdatensätze + """ + return get_ausleihungen(status='planned', start=start, end=end) + + +def get_completed_ausleihungen(start=None, end=None): + """ + Ruft alle abgeschlossenen Ausleihungen ab. + + Args: + start (str/datetime, optional): Startdatum für Datumsfilterung + end (str/datetime, optional): Enddatum für Datumsfilterung + + Returns: + list: Liste abgeschlossener Ausleihungsdatensätze + """ + return get_ausleihungen(status='completed', start=start, end=end) + + +def get_cancelled_ausleihungen(start=None, end=None): + """ + Ruft alle stornierten Ausleihungen ab. + + Args: + start (str/datetime, optional): Startdatum für Datumsfilterung + end (str/datetime, optional): Enddatum für Datumsfilterung + + Returns: + list: Liste stornierter Ausleihungsdatensätze + """ + return get_ausleihungen(status='cancelled', start=start, end=end) + + +# === SEARCH FUNCTIONS === + +def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=True): + """ + Ruft Ausleihungen für einen bestimmten Benutzer ab und verifiziert den Status clientseitig. + + Args: + user_id (str): ID oder Benutzername des Benutzers + status (str/list, optional): Status(se) der Ausleihungen + use_client_side_verification (bool, optional): Ob der Status clientseitig verifiziert werden soll + + Returns: + list: Liste von Ausleihungsdatensätzen des Benutzers + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + query = {'User': user_id} + + # Wenn clientseitige Verifikation verwendet wird, holen wir ALLE Ausleihungen + # und filtern später clientseitig + if use_client_side_verification: + # Bei clientseitiger Verifikation alle Ausleihungen holen (auch cancelled) + # da wir den Status später neu berechnen + pass # query bleibt unverändert + else: + # Exclude only cancelled status by default - we want to see planned, active, and completed + if status is not None: + if isinstance(status, list): + query['Status'] = {'$in': status} + else: + query['Status'] = status + else: + # Otherwise exclude only cancelled appointments + query['Status'] = {'$ne': 'cancelled'} + + # Get appointments from database + if not use_client_side_verification: + results = list(ausleihungen.find(query)) + client.close() + return results + + # Wenn clientseitige Statusverifikation aktiviert ist, holen wir alle Ausleihungen + # des Benutzers und verifizieren den Status anschließend + all_ausleihungen = list(ausleihungen.find(query)) + client.close() + + # Immer clientseitige Statusverifikation durchführen wenn aktiviert + if use_client_side_verification: + for ausleihung in all_ausleihungen: + # Clientseitige Statusverifizierung für alle Ausleihungen + current_status = get_current_status(ausleihung) + ausleihung['VerifiedStatus'] = current_status + + # Wenn keine Filterung erforderlich ist, geben wir alle Ausleihungen zurück + if status is None: + return all_ausleihungen + + # Statusfilterung durchführen + filtered_results = [] + for ausleihung in all_ausleihungen: + # Clientseitige Statusverifizierung + current_status = get_current_status(ausleihung) + + # Status-Matching + if isinstance(status, list): + if current_status in status: + # Status aktualisieren und zur Ergebnismenge hinzufügen + ausleihung['VerifiedStatus'] = current_status + filtered_results.append(ausleihung) + else: + if current_status == status: + # Status aktualisieren und zur Ergebnismenge hinzufügen + ausleihung['VerifiedStatus'] = current_status + filtered_results.append(ausleihung) + + return filtered_results + except Exception as e: + # print(f"Error retrieving ausleihungen for user {user_id}: {e}") # Log the error + return [] + + +def get_ausleihung_by_item(item_id, status=None, include_history=False): + """ + Get ausleihung record(s) for a specific item. + + Args: + item_id (str): ID of the item + status (str, optional): Filter by status + include_history (bool): If True, return the most recent record regardless of status + + Returns: + dict or None: Ausleihung record or None if not found + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Build query + query = {'Item': item_id} + if status and not include_history: + query['Status'] = status + + # Get the most recent record by sorting by Start date descending + ausleihung = ausleihungen.find(query).sort('Start', -1).limit(1) + + result = None + for record in ausleihung: + record['_id'] = str(record['_id']) + result = record + break + + client.close() + return result + + except Exception as e: + print(f"Error getting ausleihung by item: {e}") + return None + + +def get_ausleihungen_by_date_range(start_date, end_date, status=None): + """ + Ruft Ausleihungen ab, die in einem bestimmten Zeitraum aktiv waren. + + Args: + start_date (datetime): Beginn des Zeitraums + end_date (datetime): Ende des Zeitraums + status (str/list, optional): Status(se) der Ausleihungen + + Returns: + list: Liste von Ausleihungsdatensätzen im Zeitraum + """ + return get_ausleihungen(status=status, start=start_date, end=end_date) + + +def check_ausleihung_conflict(item_id, start_date, end_date, period=None): + """ + Prüft, ob es Konflikte mit bestehenden Ausleihungen oder aktiven Ausleihen gibt. + + Args: + item_id (str): ID des zu prüfenden Gegenstands + start_date (datetime): Vorgeschlagenes Startdatum + end_date (datetime): Vorgeschlagenes Enddatum + period (int, optional): Schulstunde für die Prüfung + + Returns: + bool: True, wenn ein Konflikt besteht, sonst False + """ + try: + print(f"Checking booking conflict for item {item_id}, period {period}, start {start_date}, end {end_date}") + + if start_date and hasattr(start_date, 'tzinfo') and start_date.tzinfo: + start_date = start_date.replace(tzinfo=None) + if end_date and hasattr(end_date, 'tzinfo') and end_date.tzinfo: + end_date = end_date.replace(tzinfo=None) + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Get the date component for filtering + booking_date = start_date.date() + + # First, get all active and planned bookings for this item + all_bookings = list(ausleihungen.find({ + 'Item': item_id, + 'Status': {'$in': ['planned', 'active']} + })) + + # Print all relevant bookings for debugging + print(f"Found {len(all_bookings)} existing bookings for this item") + for bk in all_bookings: + bk_id = str(bk.get('_id')) + bk_status = bk.get('Status') + bk_period = bk.get('Period', 'None') + bk_start = bk.get('Start') + bk_user = bk.get('User') + print(f" - Booking {bk_id}: Status={bk_status}, Period={bk_period}, Start={bk_start}, User={bk_user}") + + # If we're booking by period, check for period conflicts + if period is not None: + period_int = int(period) + + # Check bookings on the same day with the same period + for booking in all_bookings: + booking_start = booking.get('Start') + if not booking_start: + continue + + # Compare just the date part + try: + # Ensure we're comparing date objects, not datetime objects + existing_date = booking_start.date() + if existing_date == booking_date: + # If this booking has the same period, it's a conflict + booking_period = booking.get('Period') + # Convert to integer for proper comparison + if booking_period is not None and int(booking_period) == period_int: + print(f"CONFLICT: Same day, same period. Period: {period_int}, Date: {booking_date}") + client.close() + return True + except Exception as e: + print(f"Error comparing dates: {e}") + # Continue checking other bookings if there's an error with one + + # Always check for time overlaps, regardless of whether period was specified + for booking in all_bookings: + booking_start = booking.get('Start') + booking_end = booking.get('End') + + if not booking_start: + continue + + # Set default end time if not specified + if not booking_end: + booking_end = booking_start + datetime.timedelta(hours=1) + + # Check for overlap + # 1. New booking starts during existing booking + # 2. New booking ends during existing booking + # 3. New booking completely contains existing booking + # 4. Existing booking completely contains new booking + if ((start_date >= booking_start and start_date < booking_end) or + (end_date > booking_start and end_date <= booking_end) or + (start_date <= booking_start and end_date >= booking_end) or + (start_date >= booking_start and end_date <= booking_end)): + print(f"CONFLICT: Time overlap. New booking: {start_date}-{end_date}, Existing: {booking_start}-{booking_end}") + client.close() + return True + + print("No conflicts found!") + client.close() + return False + + except Exception as e: + print(f"Error checking booking conflicts: {e}") + import traceback + traceback.print_exc() + return True # Bei Fehler Konflikt annehmen, um auf Nummer sicher zu gehen + + +def check_booking_period_range_conflict(item_id, start_date, end_date, period=None, period_end=None): + """ + Checks for conflicts with existing bookings, supporting period ranges + + Args: + item_id (str): ID of the item to check + start_date (datetime): Start time for the booking + end_date (datetime): End time for the booking + period (int): Optional period number (for period-based booking) + period_end (int): Optional end period number for period ranges + + Returns: + bool: True if there's a conflict, False otherwise + """ + try: + if start_date and hasattr(start_date, 'tzinfo') and start_date.tzinfo: + start_date = start_date.replace(tzinfo=None) + if end_date and hasattr(end_date, 'tzinfo') and end_date.tzinfo: + end_date = end_date.replace(tzinfo=None) + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Get the date component for filtering + booking_date = start_date.date() + + # First, get all active and planned bookings for this item + all_bookings = list(ausleihungen.find({ + 'Item': item_id, + 'Status': {'$in': ['planned', 'active']} + })) + + # Print all relevant bookings for debugging + print(f"Found {len(all_bookings)} existing bookings for this item") + + # If we're booking by period, check for period conflicts + if period is not None: + period_start = int(period) + periods_to_check = [period_start] + + # If period_end is specified, it's a range of periods + if period_end is not None: + period_end = int(period_end) + periods_to_check = list(range(period_start, period_end + 1)) + + # Check bookings on the same day with any overlapping period + for booking in all_bookings: + booking_start = booking.get('Start') + if not booking_start: + continue + + # Compare just the date part + existing_date = booking_start.date() + if existing_date == booking_date: + booking_period = booking.get('Period') + # Normalize to int if possible + try: + booking_period_int = int(booking_period) if booking_period is not None else None + except Exception: + booking_period_int = None + + # If this booking has any period in our range, it's a conflict + if booking_period_int is not None and booking_period_int in periods_to_check: + print(f"CONFLICT: Same day, overlapping period. Booking period: {booking_period_int}") + client.close() + return True + # Always also check time overlaps against any existing bookings (incl. those without Period) + for booking in all_bookings: + booking_start = booking.get('Start') + booking_end = booking.get('End') + + if not booking_start: + continue + + # Set default end time if not specified + if not booking_end: + booking_end = booking_start + datetime.timedelta(hours=1) + + # Check for overlap of [start_date, end_date] with [booking_start, booking_end] + if ((start_date >= booking_start and start_date < booking_end) or + (end_date > booking_start and end_date <= booking_end) or + (start_date <= booking_start and end_date >= booking_end) or + (start_date >= booking_start and end_date <= booking_end)): + print(f"CONFLICT: Time overlap. New: {start_date}-{end_date}, Existing: {booking_start}-{booking_end}") + client.close() + return True + + print("No conflicts found!") + client.close() + return False + + except Exception as e: + print(f"Error checking booking conflicts: {e}") + import traceback + traceback.print_exc() + return True # Assume conflict on error for safety + + +# === AUTOMATISIERTE VERARBEITUNG === + +def get_ausleihungen_starting_now(current_time): + """ + Ruft Ausleihungen ab, die jetzt beginnen sollen (innerhalb eines Zeitfensters). + + Args: + current_time (datetime): Aktuelle Zeit für den Vergleich + + Returns: + list: Liste von Ausleihungen, die jetzt beginnen sollen + """ + try: + # Define a wider time window (3 hours before to 1 hour after) + # This helps catch bookings that might have been missed + hours_before = datetime.timedelta(hours=3) + hours_after = datetime.timedelta(hours=1) + start_time = current_time - hours_before + end_time = current_time + hours_after + + # Get today's date for date comparison + today = current_time.date() + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Build a query to find planned bookings that: + # 1. Are scheduled to start within our time window + # 2. OR have a period set for today + query = { + 'Status': 'planned', + '$or': [ + # Time-based bookings within our window + {'Start': {'$lte': end_time, '$gte': start_time}}, + + # Period-based bookings for today + { + 'Period': {'$exists': True}, + 'Start': { + '$gte': datetime.datetime.combine(today, datetime.time.min), + '$lt': datetime.datetime.combine(today + datetime.timedelta(days=1), datetime.time.min) + } + } + ] + } + + print(f"Query for bookings starting now: {query}") + bookings = list(ausleihungen.find(query)) + + print(f"Found {len(bookings)} bookings that might be starting now") + for b in bookings: + print(f" - Booking {b.get('_id')}: Start={b.get('Start')}, Period={b.get('Period')}") + + client.close() + return bookings + except Exception as e: + print(f"Error in get_ausleihungen_starting_now: {e}") + import traceback + traceback.print_exc() + return [] + + +def get_ausleihungen_ending_now(current_time): + """ + Ruft Ausleihungen ab, die jetzt enden sollen (innerhalb eines Zeitfensters). + + Args: + current_time (datetime): Aktuelle Zeit für den Vergleich + + Returns: + list: Liste von Ausleihungen, die jetzt enden sollen + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Create a wider time window (15 minutes before to catch any missed endings) + window_before = datetime.timedelta(minutes=15) + window_after = datetime.timedelta(minutes=5) + start_time = current_time - window_before + end_time = current_time + window_after + + # Get today's date for period-based checks + today = current_time.date() + + # Find active bookings that: + # 1. Have an end time within our window, OR + # 2. Are from today with a period (will check the period in process_bookings) + query = { + 'Status': 'active', + '$or': [ + {'End': {'$gte': start_time, '$lte': end_time}}, + { + 'Period': {'$exists': True}, + 'Start': { + '$gte': datetime.datetime.combine(today, datetime.time.min), + '$lt': datetime.datetime.combine(today + datetime.timedelta(days=1), datetime.time.min) + } + } + ] + } + + print(f"Looking for bookings ending now with query: {query}") + bookings = list(ausleihungen.find(query)) + print(f"Found {len(bookings)} bookings that might be ending now") + for b in bookings: + print(f" - Potential ending booking {b.get('_id')}: End={b.get('End')}, Period={b.get('Period')}") + + client.close() + return bookings + except Exception as e: + print(f"Error in get_ausleihungen_ending_now: {e}") + import traceback + traceback.print_exc() + return [] + + +def activate_ausleihung(id): + """ + Aktiviert eine geplante Ausleihe. + + Args: + id (str): ID der zu aktivierenden Ausleihe + + Returns: + bool: True bei Erfolg, sonst False + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Zuerst prüfen, ob die Ausleihe existiert und den Status 'planned' hat + ausleihung = ausleihungen.find_one({'_id': ObjectId(id)}) + if not ausleihung or ausleihung.get('Status') != 'planned': + client.close() + return False + + # Ausleihe aktivieren + result = ausleihungen.update_one( + {'_id': ObjectId(id)}, + {'$set': { + 'Status': 'active', + 'LastUpdated': datetime.datetime.now() + }} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + return False + + +# === KOMPATIBILITÄTSFUNKTIONEN === + +# Hilfsmethoden für alte Funktionsaufrufe, um Abwärtskompatibilität zu gewährleisten + +def add_planned_booking(item_id, user, start_date, end_date, notes="", period=None): + """Kompatibilitätsfunktion - erstellt eine geplante Ausleihe""" + return add_ausleihung(item_id, user, start_date, end_date, notes, status='planned', period=period) + +def check_booking_conflict(item_id, start_date, end_date, period=None): + """Kompatibilitätsfunktion - prüft auf Ausleihungskonflikte mit Periodenunterstützung""" + return check_ausleihung_conflict(item_id, start_date, end_date, period) + +def cancel_booking(booking_id): + """Kompatibilitätsfunktion - storniert eine Ausleihe""" + return cancel_ausleihung(booking_id) + +def get_booking(booking_id): + """Kompatibilitätsfunktion - ruft eine einzelne Ausleihe ab""" + return get_ausleihung(booking_id) + +def get_active_bookings(start=None, end=None): + """Kompatibilitätsfunktion - ruft aktive Ausleihungen ab""" + return get_active_ausleihungen(start, end) + +def get_planned_bookings(start=None, end=None): + """Kompatibilitätsfunktion - ruft geplante Ausleihungen ab""" + return get_planned_ausleihungen(start, end) + +def get_completed_bookings(start=None, end=None): + """Kompatibilitätsfunktion - ruft abgeschlossene Ausleihungen ab""" + return get_completed_ausleihungen(start, end) + +def mark_booking_active(booking_id, ausleihung_id=None): + """Kompatibilitätsfunktion - markiert eine Ausleihe als aktiv und verknüpft optional eine Ausleihungs-ID""" + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + + # Basisupdate-Daten mit Status-Änderung + update_data = { + 'Status': 'active', + 'LastUpdated': datetime.datetime.now() + } + + # Wenn eine Ausleihungs-ID angegeben wurde, diese auch verknüpfen + if ausleihung_id: + update_data['AusleihungId'] = ausleihung_id + + # Update durchführen + result = ausleihungen.update_one( + {'_id': ObjectId(booking_id)}, + {'$set': update_data} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + print(f"Error activating booking: {e}") + # Fallback zur alten Methode bei Fehlern + return activate_ausleihung(booking_id) + +def mark_booking_completed(booking_id): + """Kompatibilitätsfunktion - markiert eine Ausleihe als abgeschlossen""" + return complete_ausleihung(booking_id) + +def get_bookings_starting_now(current_time): + """Kompatibilitätsfunktion - ruft startende Ausleihungen ab""" + +def get_bookings_starting_now(current_time): + """Kompatibilitätsfunktion - ruft startende Ausleihungen ab""" + return get_ausleihungen_starting_now(current_time) + +def get_bookings_ending_now(current_time): + """Kompatibilitätsfunktion - ruft endende Ausleihungen ab""" + return get_ausleihungen_ending_now(current_time) + + + + +def reset_item_completely(item_id): + """ + Setzt den Ausleihstatus eines Items vollständig zurück. + + Diese Funktion: + - Markiert das Item als verfügbar + - Löscht alle Ausleihungsinformationen + - Setzt Exemplar-Status zurück + - Beendet alle aktiven Ausleihungen + + Args: + item_id (str): Die ID des Items das zurückgesetzt werden soll + + Returns: + dict: Erfolg-/Fehlerstatus mit Details + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items_collection = db['items'] + ausleihungen_collection = db['ausleihungen'] + + # Item abrufen + item = items_collection.find_one({'_id': ObjectId(item_id)}) + if not item: + return {'success': False, 'message': 'Item nicht gefunden'} + + item_name = item.get('Name', 'Unbekannt') + + # 1. Alle aktiven Ausleihungen für dieses Item beenden + active_borrowings = ausleihungen_collection.find({ + 'Item': item_id, + 'Status': {'$in': ['active', 'planned']} + }) + + completed_count = 0 + for borrowing in active_borrowings: + ausleihungen_collection.update_one( + {'_id': borrowing['_id']}, + { + '$set': { + 'Status': 'completed', + 'End': datetime.datetime.now(), + 'LastUpdated': datetime.datetime.now(), + 'CompletedBy': 'System Reset' + } + } + ) + completed_count += 1 + + # 2. Item-Status zurücksetzen + update_data = { + 'Verfuegbar': True, + 'LastUpdated': datetime.datetime.now() + } + + # Entferne User-Zuordnung falls vorhanden + if 'User' in item: + update_data['$unset'] = {'User': ''} + + # Entferne BorrowerInfo falls vorhanden + if 'BorrowerInfo' in item: + if '$unset' not in update_data: + update_data['$unset'] = {} + update_data['$unset']['BorrowerInfo'] = '' + + # Setze ExemplareStatus zurück falls vorhanden + if 'ExemplareStatus' in item: + if '$unset' not in update_data: + update_data['$unset'] = {} + update_data['$unset']['ExemplareStatus'] = '' + + # Item aktualisieren + result = items_collection.update_one( + {'_id': ObjectId(item_id)}, + update_data + ) + + client.close() + + if result.modified_count > 0: + return { + 'success': True, + 'message': f'Item "{item_name}" wurde erfolgreich zurückgesetzt', + 'details': { + 'completed_borrowings': completed_count, + 'item_reset': True + } + } + else: + return { + 'success': True, + 'message': f'Item "{item_name}" war bereits im korrekten Status', + 'details': { + 'completed_borrowings': completed_count, + 'item_reset': False + } + } + + except Exception as e: + return { + 'success': False, + 'message': f'Fehler beim Zurücksetzen: {str(e)}' + } \ No newline at end of file diff --git a/Web/ausleihung_log.py b/Web/ausleihung_log.py new file mode 100755 index 0000000..715cbdc --- /dev/null +++ b/Web/ausleihung_log.py @@ -0,0 +1,45 @@ +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +""" +Funktion zum Protokollieren von Statusänderungen bei Ausleihungen +""" +import os +import datetime +from bson.objectid import ObjectId + +def log_status_change(ausleihung_id, old_status, new_status, user=None): + """ + Protokolliert eine Statusänderung einer Ausleihung in einer Log-Datei. + + Args: + ausleihung_id: Die ID der Ausleihung + old_status: Der alte Status + new_status: Der neue Status + user: Der Benutzer, der die Änderung vorgenommen hat (optional) + """ + try: + # Erstelle Log-Verzeichnis, falls es nicht existiert + log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs') + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # Log-Datei für Statusänderungen + log_file = os.path.join(log_dir, 'ausleihungen_status_changes.log') + + # Protokolliere die Änderung + timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + user_info = f" by {user}" if user else "" + + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n") + + return True + except Exception as e: + print(f"Fehler beim Protokollieren der Statusänderung: {e}") + return False diff --git a/Web/generate_user.py b/Web/generate_user.py new file mode 100755 index 0000000..58f4848 --- /dev/null +++ b/Web/generate_user.py @@ -0,0 +1,103 @@ +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +import user +import sys +import getpass +import re + +def is_valid_username(username): + """Check if username follows valid pattern (letters, numbers, underscore)""" + return bool(re.match(r'^[a-zA-Z0-9_]+$', username)) + +def is_valid_password(password): + """Check if password meets minimum requirements""" + if len(password) < 8: + return False, "Password must be at least 8 characters long" + return True, "" + +def generate_user_interactive(): + print("========================================") + print(" User Generation Interface ") + print("========================================") + + # Get username + while True: + username = input("Enter username: ").strip() + if not username: + print("Error: Username cannot be empty") + continue + if not is_valid_username(username): + print("Error: Username can only contain letters, numbers, and underscores") + continue + break + + # Get password + while True: + password = getpass.getpass("Enter password: ") + if not password: + print("Error: Password cannot be empty") + continue + + valid, message = is_valid_password(password) + if not valid: + print(f"Error: {message}") + continue + + confirm_password = getpass.getpass("Confirm password: ") + if password != confirm_password: + print("Error: Passwords do not match") + continue + + break + + # Ask if admin + while True: + admin_input = input("Make this user an admin? (y/n): ").lower().strip() + if admin_input in ['y', 'yes']: + is_admin = True + break + elif admin_input in ['n', 'no']: + is_admin = False + break + else: + print("Please enter 'y' or 'n'") + + while True: + name_input = input("Enter a first name for the user:") + if not name_input: + print("You have to provide a name!") + else: + break + + while True: + last_name_input = input("Enter a last name for the user:") + if not last_name_input: + print("You have to provide a name!") + else: + break + + + # Add the user + added = user.add_user(username, password, name_input, last_name_input) + + if added: + print(f"User '{username}' created successfully.") + if is_admin: + admin_result = user.make_admin(username) + if admin_result: + print(f"User '{username}' has been given administrator privileges.") + else: + print(f"Warning: Failed to make user '{username}' an administrator.") + else: + print(f"Error: Failed to create user '{username}'. Username may already exist.") + + return added + +if __name__ == "__main__": + generate_user_interactive() diff --git a/Web/items.py b/Web/items.py new file mode 100755 index 0000000..0f860c4 --- /dev/null +++ b/Web/items.py @@ -0,0 +1,1021 @@ +""" +Inventory Items Management +========================= + +This module manages inventory items in the database. It provides comprehensive +functionality for creating, updating, retrieving and filtering inventory items. + +Key Features: +- Creating and updating inventory items +- Retrieving items by ID, name, or filters +- Managing item availability status +- Supporting images and categorization +- Retrieving filter/category values for UI components + +Collection Structure: +- items: Stores all inventory item records with their metadata + - Required fields: Name, Ort, Beschreibung + - Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4 + - Status fields: Verfuegbar, User (if currently borrowed) +""" +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +from pymongo import MongoClient +from bson.objectid import ObjectId +import datetime +import settings as cfg + + +LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media') + + +def _non_library_query(extra_query=None): + """Build a query that excludes library media items from normal inventory.""" + base_query = {'ItemType': {'$nin': list(LIBRARY_ITEM_TYPES)}} + if extra_query: + base_query.update(extra_query) + return base_query + + +# === ITEM MANAGEMENT === + +def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, filter3=None, + ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True, + series_group_id=None, series_count=1, series_position=1, + is_grouped_sub_item=False, parent_item_id=None, + isbn=None, item_type='general'): + """ + Add a new item to the inventory. + + Args: + name (str): Name of the item + ort (str): Location of the item + beschreibung (str): Description of the item + images (list, optional): List of image filenames for the item + filter (str, optional): Primary filter/category for the item + filter2 (str, optional): Secondary filter/category for the item + filter3 (str, optional): Tertiary filter/category for the item + ansch_jahr (int, optional): Year of acquisition + ansch_kost (float, optional): Cost of acquisition + code_4 (str, optional): 4-digit identification code + reservierbar (bool, optional): Whether the item can be reserved in advance + series_group_id (str, optional): Shared group id for same-type batch items + series_count (int, optional): Total items in the created batch + series_position (int, optional): Position inside the batch (1-based) + is_grouped_sub_item (bool, optional): Whether this item is hidden as sub-item + parent_item_id (str, optional): Parent item id if this is a sub-item + + Returns: + ObjectId: ID of the new item or None if failed + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + # Set default values for optional parameters + if images is None: + images = [] + + item = { + 'Name': name, + 'Ort': ort, + 'Beschreibung': beschreibung, + 'Images': images, + 'Verfuegbar': True, + 'Reservierbar': reservierbar, + 'Filter': filter, + 'Filter2': filter2, + 'Filter3': filter3, + 'Anschaffungsjahr': ansch_jahr, + 'Anschaffungskosten': ansch_kost, + 'Code_4': code_4, + 'ISBN': isbn, + 'ItemType': item_type, + 'SeriesGroupId': series_group_id, + 'SeriesCount': series_count, + 'SeriesPosition': series_position, + 'IsGroupedSubItem': is_grouped_sub_item, + 'ParentItemId': parent_item_id, + 'Created': datetime.datetime.now(), + 'LastUpdated': datetime.datetime.now() + } + result = items.insert_one(item) + item_id = result.inserted_id + + client.close() + return item_id + except Exception as e: + print(f"Error adding item: {e}") + return None + + +def remove_item(id): + """ + Remove an item from the inventory. + + Args: + id (str): ID of the item to remove + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + result = items.delete_one({'_id': ObjectId(id)}) + client.close() + return result.deleted_count > 0 + except Exception as e: + print(f"Error removing item: {e}") + return False + + +def get_group_item_ids(id): + """ + Resolve all item ids that belong to the same grouped series as the given item. + + For non-grouped items, this returns only the provided id. + + Args: + id (str): ID of any item in the group + + Returns: + list[str]: All related item IDs (including the parent item) + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + base_item = items.find_one({'_id': ObjectId(id)}) + if not base_item: + client.close() + return [] + + resolved_ids = set() + + # Prefer SeriesGroupId because it represents the full logical group. + series_group_id = base_item.get('SeriesGroupId') + if series_group_id: + for group_item in items.find({'SeriesGroupId': series_group_id}, {'_id': 1}): + resolved_ids.add(str(group_item['_id'])) + else: + resolved_ids.add(str(base_item['_id'])) + + parent_item_id = base_item.get('ParentItemId') + if parent_item_id: + resolved_ids.add(str(parent_item_id)) + for sibling in items.find({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}, {'_id': 1}): + resolved_ids.add(str(sibling['_id'])) + else: + for child in items.find({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}, {'_id': 1}): + resolved_ids.add(str(child['_id'])) + + client.close() + return list(resolved_ids) + except Exception as e: + print(f"Error resolving group item IDs: {e}") + return [] + + +def update_item(id, name, ort, beschreibung, images=None, verfuegbar=True, + filter=None, filter2=None, filter3=None, ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True, + isbn=None, item_type='general'): + """ + Update an existing inventory item. + + Args: + id (str): ID of the item to update + name (str): Name of the item + ort (str): Location of the item + beschreibung (str): Description of the item + images (list, optional): List of image filenames for the item + verfuegbar (bool, optional): Availability status of the item + filter (str, optional): Primary filter/category for the item + filter2 (str, optional): Secondary filter/category for the item + filter3 (str, optional): Tertiary filter/category for the item + ansch_jahr (int, optional): Year of acquisition + ansch_kost (float, optional): Cost of acquisition + code_4 (str, optional): 4-digit identification code + reservierbar (bool, optional): Whether the item can be reserved in advance + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + # Set default values for optional parameters + if images is None: + images = [] + + update_data = { + 'Name': name, + 'Ort': ort, + 'Beschreibung': beschreibung, + 'Images': images, + 'Verfuegbar': verfuegbar, + 'Reservierbar': reservierbar, + 'Filter': filter, + 'Filter2': filter2, + 'Filter3': filter3, + 'Anschaffungsjahr': ansch_jahr, + 'Anschaffungskosten': ansch_kost, + 'Code_4': code_4, + 'ISBN': isbn, + 'ItemType': item_type, + 'LastUpdated': datetime.datetime.now() + } + + result = items.update_one( + {'_id': ObjectId(id)}, + {'$set': update_data} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + print(f"Error updating item: {e}") + return False + + +def update_item_status(id, verfuegbar, user=None): + """ + Update the availability status of an inventory item. + + Args: + id (str): ID of the item to update + verfuegbar (bool): New availability status + user (str, optional): Username of person who borrowed the item + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + update_data = { + 'Verfuegbar': verfuegbar, + 'LastUpdated': datetime.datetime.now() + } + + if user is not None: + update_data['User'] = user + elif verfuegbar: + # If item is being marked as available, clear the user field + update_data['$unset'] = {'User': ""} + + result = items.update_one( + {'_id': ObjectId(id)}, + {'$set': update_data} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + print(f"Error updating item status: {e}") + return False + + +def update_item_exemplare_status(id, exemplare_status): + """ + Update the exemplar status of an inventory item. + + Args: + id (str): ID of the item to update + exemplare_status (list): List of status objects for each exemplar + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + update_data = { + 'ExemplareStatus': exemplare_status, + 'LastUpdated': datetime.datetime.now() + } + + result = items.update_one( + {'_id': ObjectId(id)}, + {'$set': update_data} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + print(f"Error updating exemplar status: {e}") + return False + + +def is_code_unique(code_4, exclude_id=None): + """ + Check if a given code is unique (not used by any other item). + + Args: + code_4 (str): The code to check + exclude_id (str, optional): ID of item to exclude from the check (for edit operations) + + Returns: + bool: True if code is unique, False if already in use + """ + if not code_4 or code_4.strip() == "": + # Empty codes are not considered unique + return False + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + # Build query to find items with this code + query = {'Code_4': code_4} + + # If we're editing an item, exclude it from the uniqueness check + if exclude_id: + query['_id'] = {'$ne': ObjectId(exclude_id)} + + # Check if any items with this code exist + count = items.count_documents(query) + + client.close() + return count == 0 + + +# === ITEM RETRIEVAL === + +def get_items(): + """ + Retrieve all inventory items. + + Returns: + list: List of all inventory item documents with string IDs + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + items_return = items.find(_non_library_query()) + items_list = [] + for item in items_return: + item['_id'] = str(item['_id']) + items_list.append(item) + client.close() + return items_list + except Exception as e: + print(f"Error retrieving items: {e}") + return [] + + +def get_available_items(): + """ + Retrieve all available inventory items. + + Returns: + list: List of available inventory item documents with string IDs + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + items_return = items.find(_non_library_query({'Verfuegbar': True})) + items_list = [] + for item in items_return: + item['_id'] = str(item['_id']) + items_list.append(item) + client.close() + return items_list + except Exception as e: + print(f"Error retrieving available items: {e}") + return [] + + +def get_borrowed_items(): + """ + Retrieve all currently borrowed inventory items. + + Returns: + list: List of borrowed inventory item documents with string IDs + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + items_return = items.find(_non_library_query({'Verfuegbar': False})) + items_list = [] + for item in items_return: + item['_id'] = str(item['_id']) + items_list.append(item) + client.close() + return items_list + except Exception as e: + print(f"Error retrieving borrowed items: {e}") + return [] + + +def get_item(id): + """ + Retrieve a specific inventory item by its ID. + + Args: + id (str): ID of the item to retrieve + + Returns: + dict: The inventory item document or None if not found + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + item = items.find_one({'_id': ObjectId(id)}) + client.close() + return item + except Exception as e: + print(f"Error retrieving item: {e}") + return None + + +def get_item_by_name(name): + """ + Retrieve a specific inventory item by its name. + + Args: + name (str): Name of the item to retrieve + + Returns: + dict: The inventory item document or None if not found + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + item = items.find_one({'Name': name}) + client.close() + return item + except Exception as e: + print(f"Error retrieving item by name: {e}") + return None + + +def get_items_by_filter(filter_value): + """ + Retrieve inventory items matching a specific filter/category. + + Args: + filter_value (str): Filter value to search for + + Returns: + list: List of items matching the filter in primary, secondary, or tertiary category + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + # Use $or to find matches in any filter field + query = _non_library_query({ + '$or': [ + {'Filter': filter_value}, + {'Filter2': filter_value}, + {'Filter3': filter_value} + ] + }) + + results = list(items.find(query)) + client.close() + + # Convert ObjectId to string + for item in results: + item['_id'] = str(item['_id']) + + return results + except Exception as e: + print(f"Error retrieving items by filter: {e}") + return [] + + +def get_filters(): + """ + Retrieve all unique filter/category values from the inventory. + + Returns: + list: Combined list of all primary, secondary and tertiary filter values + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + non_library = _non_library_query() + filters = items.distinct('Filter', non_library) + filters2 = items.distinct('Filter2', non_library) + filters3 = items.distinct('Filter3', non_library) + + # Combine filters and remove None/empty values + all_filters = [f for f in filters + filters2 + filters3 if f] + + # Remove duplicates while preserving order + unique_filters = [] + for f in all_filters: + if f not in unique_filters: + unique_filters.append(f) + + client.close() + return unique_filters + except Exception as e: + print(f"Error retrieving filters: {e}") + return [] + + +def get_primary_filters(): + """ + Retrieve all unique primary filter values. + + Returns: + list: List of all primary filter values + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + filters = [f for f in items.distinct('Filter', _non_library_query()) if f] + client.close() + return filters + except Exception as e: + print(f"Error retrieving primary filters: {e}") + return [] + + +def get_secondary_filters(): + """ + Retrieve all unique secondary filter values. + + Returns: + list: List of all secondary filter values + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + filters = [f for f in items.distinct('Filter2', _non_library_query()) if f] + client.close() + return filters + except Exception as e: + print(f"Error retrieving secondary filters: {e}") + return [] + + +def get_tertiary_filters(): + """ + Retrieve all unique tertiary filter values. + + Returns: + list: List of all tertiary filter values + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + filters = [f for f in items.distinct('Filter3', _non_library_query()) if f] + client.close() + return filters + except Exception as e: + print(f"Error retrieving tertiary filters: {e}") + return [] + + +def get_item_by_code_4(code_4): + """ + Retrieve inventory items matching a specific 4-digit code. + + Args: + code_4 (str): 4-digit code to search for + + Returns: + list: List of items matching the code + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + results = list(items.find(_non_library_query({"Code_4": code_4}))) + + # Convert ObjectId to string + for item in results: + item['_id'] = str(item['_id']) + + client.close() + return results + except Exception as e: + print(f"Error retrieving item by code: {e}") + return [] + + +# === MAINTENANCE FUNCTIONS === + +def unstuck_item(id): + """ + Remove all borrowing records for a specific item to reset its status. + Used to fix problematic or stuck items. + + Args: + id (str): ID of the item to unstick + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + ausleihungen = db['ausleihungen'] + result = ausleihungen.delete_many({'Item': id}) + + # Also reset the item status + items = db['items'] + items.update_one( + {'_id': ObjectId(id)}, + { + '$set': { + 'Verfuegbar': True, + 'LastUpdated': datetime.datetime.now() + }, + '$unset': {'User': ""} + } + ) + + client.close() + return True + except Exception as e: + print(f"Error unsticking item: {e}") + return False + + +def get_predefined_filter_values(filter_num): + """ + Get predefined values for a specific filter. + + Args: + filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe) + + Returns: + list: List of predefined filter values + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + + # Use a dedicated collection for filter presets + filter_presets = db['filter_presets'] + + # Find the document for the specified filter + filter_doc = filter_presets.find_one({'filter_num': filter_num}) + + client.close() + + if filter_doc and 'values' in filter_doc: + # Sort values alphabetically + return sorted(filter_doc['values']) + else: + # Create empty document if it doesn't exist + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + filter_presets = db['filter_presets'] + filter_presets.update_one( + {'filter_num': filter_num}, + {'$set': {'values': []}}, + upsert=True + ) + client.close() + return [] + +def add_predefined_filter_value(filter_num, value): + """ + Add a new predefined value to a filter. + + Args: + filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe) + value (str): Value to add + + Returns: + bool: True if value was added, False if it already existed + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + filter_presets = db['filter_presets'] + + # Check if value already exists + filter_doc = filter_presets.find_one({ + 'filter_num': filter_num, + 'values': value + }) + + if filter_doc: + # Value already exists + client.close() + return False + + # Add the value to the filter + result = filter_presets.update_one( + {'filter_num': filter_num}, + {'$push': {'values': value}}, + upsert=True + ) + + client.close() + return result.modified_count > 0 or result.upserted_id is not None + +def remove_predefined_filter_value(filter_num, value): + """ + Remove a predefined value from a filter. + + Args: + filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe) + value (str): Value to remove + + Returns: + bool: True if value was removed, False otherwise + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + filter_presets = db['filter_presets'] + + # Remove the value from the filter + result = filter_presets.update_one( + {'filter_num': filter_num}, + {'$pull': {'values': value}} + ) + + client.close() + return result.modified_count > 0 + + +# === LOCATION MANAGEMENT === + +def get_predefined_locations(): + """ + Get list of all predefined locations/placement options. + + Returns: + list: List of predefined location strings + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + + # Check if settings collection exists, create if not + if 'settings' not in db.list_collection_names(): + db.create_collection('settings') + + # Get settings document or create if it doesn't exist + settings_collection = db['settings'] + location_settings = settings_collection.find_one({'setting_type': 'predefined_locations'}) + + if not location_settings: + # Create default settings document if it doesn't exist + settings_collection.insert_one({ + 'setting_type': 'predefined_locations', + 'locations': [] + }) + return [] + + # Return the predefined locations + locations = location_settings.get('locations', []) + client.close() + return sorted(locations) + + except Exception as e: + print(f"Error getting predefined locations: {str(e)}") + return [] + + +def add_predefined_location(location): + """ + Add a new predefined location. + + Args: + location (str): Location to add + + Returns: + bool: True if added successfully, False if already exists + """ + if not location or not isinstance(location, str): + return False + + location = location.strip() + if not location: + return False + + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + settings_collection = db['settings'] + + # Check if settings document exists, create if not + location_settings = settings_collection.find_one({'setting_type': 'predefined_locations'}) + + if not location_settings: + # Create with the new location + settings_collection.insert_one({ + 'setting_type': 'predefined_locations', + 'locations': [location] + }) + client.close() + return True + + # Check if location already exists (case-insensitive) + current_locations = location_settings.get('locations', []) + if any(loc.lower() == location.lower() for loc in current_locations): + client.close() + return False + + # Add the new location + settings_collection.update_one( + {'setting_type': 'predefined_locations'}, + {'$push': {'locations': location}} + ) + + client.close() + return True + + except Exception as e: + print(f"Error adding predefined location: {str(e)}") + return False + + +def remove_predefined_location(location): + """ + Remove a predefined location. + + Args: + location (str): Location to remove + + Returns: + bool: True if removed successfully + """ + if not location: + return False + + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + settings_collection = db['settings'] + + result = settings_collection.update_one( + {'setting_type': 'predefined_locations'}, + {'$pull': {'locations': location}} + ) + + client.close() + return result.modified_count > 0 + + except Exception as e: + print(f"Error removing predefined location: {str(e)}") + return False + + +def update_item_next_appointment(item_id, appointment_data): + """ + Update an item with information about its next scheduled appointment. + + Args: + item_id (str): ID of the item to update + appointment_data (dict): Appointment information containing: + - date: Date of the appointment + - start_period: Start period number + - end_period: End period number + - user: Username who scheduled the appointment + - notes: Optional notes + - appointment_id: ID of the appointment booking + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + # Format the appointment data for storage + # Ensure date is a datetime object for MongoDB storage + appointment_date = appointment_data['date'] + if isinstance(appointment_date, datetime.date) and not isinstance(appointment_date, datetime.datetime): + # Convert date to datetime for MongoDB compatibility + appointment_date = datetime.datetime.combine(appointment_date, datetime.time()) + + next_appointment = { + 'date': appointment_date, + 'end_date': appointment_data.get('end_date', appointment_date), + 'start_period': appointment_data['start_period'], + 'end_period': appointment_data['end_period'], + 'user': appointment_data['user'], + 'notes': appointment_data.get('notes', ''), + 'appointment_id': appointment_data['appointment_id'], + 'scheduled_at': datetime.datetime.now() + } + + update_data = { + 'NextAppointment': next_appointment, + 'LastUpdated': datetime.datetime.now() + } + + result = items.update_one( + {'_id': ObjectId(item_id)}, + {'$set': update_data} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + print(f"Error updating item next appointment: {e}") + return False + + +def clear_item_next_appointment(item_id): + """ + Clear the next appointment information from an item. + + Args: + item_id (str): ID of the item to update + + Returns: + bool: True if successful, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + result = items.update_one( + {'_id': ObjectId(item_id)}, + {'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now()}} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + print(f"Error clearing item next appointment: {e}") + return False + + +def get_items_with_appointments(): + """ + Retrieve all items that have scheduled appointments. + + Returns: + list: List of items with NextAppointment field + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + items_return = items.find({'NextAppointment': {'$exists': True}}) + items_list = [] + for item in items_return: + item['_id'] = str(item['_id']) + items_list.append(item) + client.close() + return items_list + except Exception as e: + print(f"Error retrieving items with appointments: {e}") + return [] + +def get_current_status(item_id): + """ + Retrieve the current status of an item, including availability and user. + + Args: + item_id (str): ID of the item to check + + Returns: + dict: Current status of the item or None if not found + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + item = items.find_one({'_id': ObjectId(item_id)}, {'Verfuegbar': 1, 'User': 1}) + + if item: + # Convert ObjectId to string for consistency + item['_id'] = str(item['_id']) + client.close() + return item + else: + client.close() + return None + except Exception as e: + print(f"Error retrieving current status: {e}") + return None \ No newline at end of file diff --git a/Web/requirements.txt b/Web/requirements.txt new file mode 100755 index 0000000..8a8b6fb --- /dev/null +++ b/Web/requirements.txt @@ -0,0 +1,13 @@ +flask +bson +werkzeug +gunicorn +pymongo +pillow +qrcode +apscheduler +python-dateutil +pytz +requests +reportlab +python-barcode \ No newline at end of file diff --git a/Web/settings.py b/Web/settings.py new file mode 100644 index 0000000..7d62002 --- /dev/null +++ b/Web/settings.py @@ -0,0 +1,175 @@ +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +""" +Centralized settings module to load configuration from config.json and provide +defaults for the web application and helper modules. +""" +import os +import json + +# Base directory of this Web package +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Default values +DEFAULTS = { + 'version': '2.6.5', + 'debug': False, + 'secret_key': 'Hsse783942h2342f342342i34hwebf8', + 'host': '0.0.0.0', + 'port': 443, + 'mongodb': { + 'host': 'localhost', + 'port': 27017, + 'db': 'Inventarsystem', + }, + 'scheduler': { + 'interval_minutes': 1, + 'backup_interval_hours': 24, + 'enabled': True, + }, + 'ssl': { + 'enabled': False, + 'cert': 'ssl_certs/cert.pem', + 'key': 'ssl_certs/key.pem', + }, + 'images': { + 'thumbnail_size': [150, 150], + 'preview_size': [400, 400], + }, + 'upload': { + 'folder': os.path.join(BASE_DIR, 'uploads'), + 'thumbnail_folder': os.path.join(BASE_DIR, 'thumbnails'), + 'preview_folder': os.path.join(BASE_DIR, 'previews'), + 'qrcode_folder': os.path.join(BASE_DIR, 'QRCodes'), + 'max_size_mb': 10, + 'image_max_size_mb': 15, + 'video_max_size_mb': 100, + 'allowed_extensions': ['png', 'jpg', 'jpeg', 'gif'] + }, + 'paths': { + 'backups': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'backups'), + 'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'), + }, + 'schoolPeriods': { + "1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"}, + "2": {"start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)"}, + "3": {"start": "09:45", "end": "10:30", "label": "3. Stunde (09:45 - 10:30)"}, + "4": {"start": "10:30", "end": "11:15", "label": "4. Stunde (10:30 - 11:15)"}, + "5": {"start": "11:30", "end": "12:15", "label": "5. Stunde (11:30 - 12:15)"}, + "6": {"start": "12:15", "end": "13:00", "label": "6. Stunde (12:15 - 13:00)"}, + "7": {"start": "13:30", "end": "14:15", "label": "7. Stunde (13:30 - 14:15)"}, + "8": {"start": "14:15", "end": "15:00", "label": "8. Stunde (14:15 - 15:00)"}, + "9": {"start": "15:15", "end": "16:00", "label": "9. Stunde (15:15 - 16:00)"}, + "10": {"start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)"} + }, + 'modules': { + 'library': { + 'enabled': False + }, + 'student_cards': { + 'enabled': False, + 'default_borrow_days': 14, + 'max_borrow_days': 365 + } + } +} + +# Load configuration file +CONFIG_PATH = os.path.join(BASE_DIR, '..', 'config.json') +try: + with open(CONFIG_PATH, 'r') as f: + _conf = json.load(f) +except Exception: + _conf = {} + +# Helper to get nested values with defaults +def _get(conf, path, default): + cur = conf + for p in path: + if isinstance(cur, dict) and p in cur: + cur = cur[p] + else: + return default + return cur + +# Expose settings +APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version']) +DEBUG = _get(_conf, ['dbg'], DEFAULTS['debug']) +SECRET_KEY = str(_get(_conf, ['key'], DEFAULTS['secret_key'])) +HOST = _get(_conf, ['host'], DEFAULTS['host']) +PORT = _get(_conf, ['port'], DEFAULTS['port']) + +# Database +MONGODB_HOST = _get(_conf, ['mongodb', 'host'], DEFAULTS['mongodb']['host']) +MONGODB_PORT = _get(_conf, ['mongodb', 'port'], DEFAULTS['mongodb']['port']) +MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db']) + +# Optional environment overrides for containerized/runtime deployments. +MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST) +MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT))) +MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB) + +# Scheduler +SCHEDULER_INTERVAL_MIN = _get(_conf, ['scheduler', 'interval_minutes'], DEFAULTS['scheduler']['interval_minutes']) +BACKUP_INTERVAL_HOURS = _get(_conf, ['scheduler', 'backup_interval_hours'], DEFAULTS['scheduler']['backup_interval_hours']) +SCHEDULER_ENABLED = _get(_conf, ['scheduler', 'enabled'], DEFAULTS['scheduler']['enabled']) + +# SSL +SSL_ENABLED = _get(_conf, ['ssl', 'enabled'], DEFAULTS['ssl']['enabled']) +SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert']) +SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key']) + +# School periods +SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods']) + +# Optional feature modules +LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled'])) +STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled'])) +STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days'])) +STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days'])) + +# Upload/paths +ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions'])) +UPLOAD_FOLDER = _get(_conf, ['upload', 'folder'], DEFAULTS['upload']['folder']) +THUMBNAIL_FOLDER = _get(_conf, ['upload', 'thumbnail_folder'], DEFAULTS['upload']['thumbnail_folder']) +PREVIEW_FOLDER = _get(_conf, ['upload', 'preview_folder'], DEFAULTS['upload']['preview_folder']) +QR_CODE_FOLDER = _get(_conf, ['upload', 'qrcode_folder'], DEFAULTS['upload']['qrcode_folder']) + +# Normalize to absolute paths to avoid cwd issues +if not os.path.isabs(UPLOAD_FOLDER): + UPLOAD_FOLDER = os.path.join(BASE_DIR, os.path.relpath(UPLOAD_FOLDER, BASE_DIR)) +if not os.path.isabs(THUMBNAIL_FOLDER): + THUMBNAIL_FOLDER = os.path.join(BASE_DIR, os.path.relpath(THUMBNAIL_FOLDER, BASE_DIR)) +if not os.path.isabs(PREVIEW_FOLDER): + PREVIEW_FOLDER = os.path.join(BASE_DIR, os.path.relpath(PREVIEW_FOLDER, BASE_DIR)) +if not os.path.isabs(QR_CODE_FOLDER): + QR_CODE_FOLDER = os.path.join(BASE_DIR, os.path.relpath(QR_CODE_FOLDER, BASE_DIR)) +MAX_UPLOAD_MB = _get(_conf, ['upload', 'max_size_mb'], DEFAULTS['upload']['max_size_mb']) +IMAGE_MAX_UPLOAD_MB = _get(_conf, ['upload', 'image_max_size_mb'], DEFAULTS['upload']['image_max_size_mb']) +VIDEO_MAX_UPLOAD_MB = _get(_conf, ['upload', 'video_max_size_mb'], DEFAULTS['upload']['video_max_size_mb']) + +THUMBNAIL_SIZE_LIST = _get(_conf, ['images', 'thumbnail_size'], DEFAULTS['images']['thumbnail_size']) +PREVIEW_SIZE_LIST = _get(_conf, ['images', 'preview_size'], DEFAULTS['images']['preview_size']) +THUMBNAIL_SIZE = (int(THUMBNAIL_SIZE_LIST[0]), int(THUMBNAIL_SIZE_LIST[1])) if isinstance(THUMBNAIL_SIZE_LIST, (list, tuple)) else (150, 150) +PREVIEW_SIZE = (int(PREVIEW_SIZE_LIST[0]), int(PREVIEW_SIZE_LIST[1])) if isinstance(PREVIEW_SIZE_LIST, (list, tuple)) else (400, 400) + +BACKUP_FOLDER = _get(_conf, ['paths', 'backups'], DEFAULTS['paths']['backups']) +LOGS_FOLDER = _get(_conf, ['paths', 'logs'], DEFAULTS['paths']['logs']) + +# Optional environment overrides for writable storage mounts. +BACKUP_FOLDER = os.getenv('INVENTAR_BACKUP_FOLDER', BACKUP_FOLDER) +LOGS_FOLDER = os.getenv('INVENTAR_LOGS_FOLDER', LOGS_FOLDER) + +# Normalize backup and logs paths to absolute paths (similar to upload folders) to avoid +# permission issues caused by relative paths resolving to unintended working dirs. +PROJECT_ROOT = os.path.dirname(os.path.dirname(BASE_DIR)) +if not os.path.isabs(BACKUP_FOLDER): + BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER) +if not os.path.isabs(LOGS_FOLDER): + LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER) diff --git a/Web/ssl_certs/cert.pem b/Web/ssl_certs/cert.pem new file mode 100755 index 0000000..8ac1ec7 --- /dev/null +++ b/Web/ssl_certs/cert.pem @@ -0,0 +1,35 @@ +-----BEGIN CERTIFICATE----- +MIIGHzCCBAegAwIBAgIUVut2lJrczNXMSH+MF+UopRQYhZcwDQYJKoZIhvcNAQEL +BQAwgZ4xCzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCYXllcm4xETAPBgNVBAcMCE11 +ZW5jaGVuMRcwFQYDVQQKDA5JbnZlbnRhcnN5c3RlbTEMMAoGA1UECwwDV2ViMR4w +HAYDVQQDDBVNQVhJTUlMSUFOR1JVRU5ESU5HRVIxJDAiBgkqhkiG9w0BCQEWFWly +b24uYWkuZGV2QGdtYWlsLmNvbTAeFw0yNTAzMTMxNzMwMTFaFw0yNjAzMTMxNzMw +MTFaMIGeMQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmF5ZXJuMREwDwYDVQQHDAhN +dWVuY2hlbjEXMBUGA1UECgwOSW52ZW50YXJzeXN0ZW0xDDAKBgNVBAsMA1dlYjEe +MBwGA1UEAwwVTUFYSU1JTElBTkdSVUVORElOR0VSMSQwIgYJKoZIhvcNAQkBFhVp +cm9uLmFpLmRldkBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQDVxGySge3gmY09/Q+ryS3t7QXA5bISEXXX+UjQm22+QgWwAJLqTr5cvJjC +LBtjUFTGVe+DdaFQLS7D2TOF93FN94KHNDD5BaEly41weqkf8tfjA0rJrqvpHLaY +2gtMKTLkt590o0faLyU40qnMkBDSfySb5GyQyHc3fxoWjYjr/GXRmPo5mwT55qbP +8KGxq29KJ6oC56shwU0FV1v9n2B1Ph85zrK28ZShliE7YWlsDc/tPM3ncjTn43QJ +dXdi+0RBCagBIU3uhqAt5r9Py318mSVDZXtD23GYw5sLKyaiqouym3XAm7xSZiMD +ti0zli7egam5ZqsYzYTeW9QXMgP6zigV3FFNoDog7QJojHdJLDA5ItVgqwHLsLpa +ww7brOW9dzjI/sDq68YAqGJLQD9P8XZLzzgQQ/1y5E1AOft4cbVZOy2P8oumHRN2 +qtbnEUE4tD6KtHQDvCro51eLs2Xrh9n48o3tVFI2qhW2Xkw1iAid4JWrKsH1jtsL +t0W35+qZ3Z6n5gAKvNg+p4ck2dy8WUX3J/i5hbC8OP++0/fys2EAkKWHfulxeG0A +InrNcThD76d6gLXtEb7DXDVGcASOivIoGsX/3SHr0Ka7lD/VpPzx8Ey9SgdFwBYo +UbIr27XRKq0aSAlAQ5hHXpzDKdYXu6KE6najW/IrjGWhVoriNwIDAQABo1MwUTAd +BgNVHQ4EFgQUnVsBQiaQ7Ygu7EC9mmaz6yNd7LkwHwYDVR0jBBgwFoAUnVsBQiaQ +7Ygu7EC9mmaz6yNd7LkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AgEAqxYI67nzcwUH6vgyeG/q9vcwHkiZwhTpH0C6+qzHdg0sENbPWN5pozvYz4zQ +YEqQyskeGEziE/PHHcBnTC8txoxl2IRHGbvfWVbmOd7HytOvSWhA8mm5As/DsUKD +dX4T0GkSXMcJ7HH++X5G6pXsfh4vwLIgqu7u+mrXUSvHvl3QP8hDvFcyuGsH2ku+ +1pD9Sy1SFqq4Ts2awIInxDKbk+ca29TkcF9AgfiNcOdve9C94F14amR34pTUc3h8 +7flaTn4MhCptRBI6ZG4SAVfHAenKDezTZK8TNdY9VlnamDXHt6yWiXnph/I9zBY5 +JhXNfiVp5MTvX8zpWsSQSl1h3HZwPMHRtECLWT4Cp2TWeOZAKB9rTaHA91PfYQjb +UENHZEDUiq6Rbk/XGFngeug1Qc1a4gVpFrSPbC8qMHNLO+zrXI7ph8hdj9K2Pfyl +ZB5iISpL8woyttMbBxGdEciXamhuiQT1+BHxVlxOqziKaMIdoE/6jhvAgV/vd8rD +QNutIws84KTYe+M9a278+WTi8w++GH3fIQetylmyb/ocXsr52Nw79e8afUlk1bTp +RlYBhCReDd0fU2xRiXsO556xXt/JWwzmtnBpqYWT5iP9107AZ1rDr6dmx2shPTXG +1IxE/ysd33tNmAHGrWM/fL0mGrB/TGVQgEEFbVz/PFt9PF0= +-----END CERTIFICATE----- diff --git a/Web/ssl_certs/key.pem b/Web/ssl_certs/key.pem new file mode 100755 index 0000000..7cd1950 --- /dev/null +++ b/Web/ssl_certs/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDVxGySge3gmY09 +/Q+ryS3t7QXA5bISEXXX+UjQm22+QgWwAJLqTr5cvJjCLBtjUFTGVe+DdaFQLS7D +2TOF93FN94KHNDD5BaEly41weqkf8tfjA0rJrqvpHLaY2gtMKTLkt590o0faLyU4 +0qnMkBDSfySb5GyQyHc3fxoWjYjr/GXRmPo5mwT55qbP8KGxq29KJ6oC56shwU0F +V1v9n2B1Ph85zrK28ZShliE7YWlsDc/tPM3ncjTn43QJdXdi+0RBCagBIU3uhqAt +5r9Py318mSVDZXtD23GYw5sLKyaiqouym3XAm7xSZiMDti0zli7egam5ZqsYzYTe +W9QXMgP6zigV3FFNoDog7QJojHdJLDA5ItVgqwHLsLpaww7brOW9dzjI/sDq68YA +qGJLQD9P8XZLzzgQQ/1y5E1AOft4cbVZOy2P8oumHRN2qtbnEUE4tD6KtHQDvCro +51eLs2Xrh9n48o3tVFI2qhW2Xkw1iAid4JWrKsH1jtsLt0W35+qZ3Z6n5gAKvNg+ +p4ck2dy8WUX3J/i5hbC8OP++0/fys2EAkKWHfulxeG0AInrNcThD76d6gLXtEb7D +XDVGcASOivIoGsX/3SHr0Ka7lD/VpPzx8Ey9SgdFwBYoUbIr27XRKq0aSAlAQ5hH +XpzDKdYXu6KE6najW/IrjGWhVoriNwIDAQABAoICACRydyiLqqlOxPw6t39Mr94P +OZnoD/Jws6z9PeMDfTShQSL/Fg2JG3/oUAtbRdgrDCk84beCSNzIt16BG/3NcK4g +c0rmJStwQEeXaygwYcGmwBRerVOxynHWiXOKSb7Uj27bt/5FEK0suKX1lSnhrlyV +fQJvcetFor39l88clqnVwO55rMBBpBmPsAVoO8l1N2ZnWN9h7JW2xZERkgeuMt4K +l3xdt67lhbf/Ru8+7qCTwk3qvhimKksXRuON+asQuFR2dwSqTwVKQulQnHHYei7S +Vb4mAVxSgA3R3A+NberyNRtg0jTi2Lsb2wB5AT/4bUlWxj9sN+PktrDK62CBEJhU +waWUR5XlaHUih7pJJzjNPc3LYBC/TGmaJyh/jiTOh6v0JFW6IQWmZr+3CPFNEgSF +grXDsyzuS8JfPJ8vtPIor/Y8GadtxSL+4n2MSL0FashOdw4GhiCLl0V3uAFAq+cJ +HscXmhH39J0GYUmUi2KxFMRpj/whnh9dkyJ/LyclzSbU34LH5nDVD3LffOi5CFzk +eFYsPhIxNROCQheqtlTU9/PfLx3lRcf96mX7fz3yOXGYR0fQTxYB0lhyRN1ojuEd +FrejAM3aNivE0a/h/l96qjEhYnS62GPme2t9rnyD6vuorP/0obIHhcnyCmxJFuhC +bqtUtLTpVeB9NCJFOPvBAoIBAQD4AXJekexNDKetv9zBeu6F78vCjaQRgzKry00G +MRTIMKh5HC5a66SmWmH3egOn2m9fXfBD/6jhiv1ddVs4TmRb7kGKmyxuNE9K1Fx6 +HlQH0W0DmQ0xB/aSHfLY/B4jz230BqUMeFMZaM09dIJmsxj6o9t3Rhev/pAomMbo +rwjPimjfZ1tPBKeBWVo6PjFfflHgMg/ZYG/4mbAmZvTTjWo72/jziFvfKQVXtCI7 +G9Qun2cfI2B/Afr4M2CkIzrJLaYFCD85tCWW1cdsrs9b1vu0deocpF4lTTRGQgzE +uKSR/90Cu8oUVDmyMYdl6qtrH+ES5C+HChK+3hcLGRT+ocYnAoIBAQDcqHDcbqPh +KvDodLN0E0P1JBYbRwvuAc61R7OjVFo8t8Y7Lx5dHVr+zTcYcmvW0UCfIDWkCJk0 +JwXigficOeNuk0tISVyFA/lNAwWB6Jw+Rb0XKXFJ9aBYvv736sdO3NT61ajjK8oc +s97uenCLwo0IBaW4V6XFZjhdW7KWAhR5dn3VbYNQKTzBJej8zN0qKGUjgJ8I2anW +PI7QcOGI9jRypQoEJFbqZqF4UoF8ZcSUIERIrvzUC4BxmovOWuhiDlvau0sgBGhX +V5kG9wk7bYmAIw35+Sad5ZSvrvKbi9RNnfQSe8TS8SCQ8oPb0fn5A56YdLSGwD3/ +e69DMqAy0B1xAoIBAQClILF57kcb9jUfJwRhfuyaGVPeMljvoB9462BZowxnTp6e +JPloaEW0zbE1CfStKm/FW9LCM9PKeLTCKYWXM/r2iujw1Oj8Z6/z7vm0BcWFfxfK +sXlrEBZIq9AqUZPv3Akl1dbHOsZ5wKIHLTA2GUGkgL176RuUfzaVEUQ/YWvIZCv3 +s+XD8yFkqo29AfP11THGyQ5seh2TtSDoMN0KjroRKSHVZRFmwPVhs/qmyJy6fkA3 +J2L0rejgobTTFPHrSY2lBBy02xG8IJMr9ijyyW3GdkxuzbIxu/n2XbwKr7ZDz8zk +KQ0gBAR2dSvl3B5OsWc3IN/UVgytMUq+hPDJqgU5AoIBAEMkpiMNL6TGkkPJrwl1 +C0q+zlNCjSjBOAsFZG4grsynBxTfQ5gF5Lh/7XHs70+QoN9Kx8fALg4mia3g6qtv +Gft9qny2hgabrPJ4JubexxxT0DQKEkT1DvOyOpdpw3gFznD9LsThgEC3ovBiJXkB +nDSCs2iikvi8IA6YZoEq5NwI6EVXa4uq4KfNR1YVaH75h70D2GCzCvD7wGFA1//7 +Gv5/pcqY3DG7RQr94BTgSq7gGqcbSCel0FHBOyVxmCCuSdkHANcfQDjDmPb7mL2S +mTJ6eDhGTi/huhqzW3NlWxa7J5ewPbYKVWSFxwXOoQfayBmP+Za/TJ4/HpeOAh00 +IqECggEABqDsPXCj6z4UfJ1ZvGnYv1J3TreEahl5CMAP4kqNO8fMD0uQzciU8phn +Mw90rkPiIFBjwraf5hY/Ry5izoTr87TFGuBCGEVGtODc9X9OA9G/d74HSo3PsyYi +VuUZd/0S7gNQ9ov12SJCLrE59IT2I8igpTCKeFVz+kDQFXAkOO6IDeCfAYn+OyXm +yEZt8Rz1xAe8aEgavGK3wv3Rtn4bZQI1xsUGJf8Zu+rP+oSiyI0E+wK4g2A1WE6G +IIlIpC6hqz5SFWBvcYqu4/10xn27iiFkqKclvXYLa0Fi1ojT1AwgVqasEbTWRdXp +0L8vtAQKY0EQkaBWKCQr9Cuacr7qqA== +-----END PRIVATE KEY----- diff --git a/Web/static/css/planned_appointments.css b/Web/static/css/planned_appointments.css new file mode 100755 index 0000000..5354c31 --- /dev/null +++ b/Web/static/css/planned_appointments.css @@ -0,0 +1,99 @@ +/* + * Copyright 2025-2026 AIIrondev + * + * Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + * See Legal/LICENSE for the full license text. + * Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + * For commercial licensing inquiries: https://github.com/AIIrondev + */ +/* Styles for planned appointments in item details */ +.planned-appointments-panel { + margin: 15px 0; + padding: 15px; + background-color: #e1f5fe; + border-left: 4px solid #03a9f4; + border-radius: 4px; +} + +.planned-appointments-panel h4 { + color: #0277bd; + margin-top: 0; + margin-bottom: 10px; + font-size: 1.1em; +} + +.appointment-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.appointment-item { + padding: 10px; + background-color: #f9fdff; + border-radius: 4px; + border: 1px solid #b3e5fc; +} + +.appointment-header { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 5px; +} + +.appointment-date { + font-weight: bold; + color: #0288d1; +} + +.appointment-period { + background-color: #03a9f4; + color: white; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.8em; +} + +.appointment-status { + padding: 2px 8px; + border-radius: 10px; + font-size: 0.8em; + font-weight: bold; + margin-left: 5px; +} + +.status-planned { + background-color: #03a9f4; + color: white; +} + +.status-active { + background-color: #4CAF50; + color: white; +} + +.status-completed { + background-color: #9E9E9E; + color: white; +} + +.status-cancelled { + background-color: #F44336; + color: white; +} + +.appointment-user { + margin-left: auto; + font-style: italic; + color: #546e7a; +} + +.appointment-notes { + font-size: 0.9em; + color: #455a64; + margin-top: 5px; + padding-top: 5px; + border-top: 1px solid #e1f5fe; + font-style: italic; +} diff --git a/Web/static/css/styles.css b/Web/static/css/styles.css new file mode 100755 index 0000000..5b269b4 --- /dev/null +++ b/Web/static/css/styles.css @@ -0,0 +1,806 @@ +/* + * Copyright 2025-2026 AIIrondev + * + * Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + * See Legal/LICENSE for the full license text. + * Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + * For commercial licensing inquiries: https://github.com/AIIrondev + */ +/* Global visual system for all pages */ +:root { + --ui-bg: #f2f5f8; + --ui-bg-accent: #e7edf4; + --ui-surface: #ffffff; + --ui-surface-soft: #f8fafc; + --ui-border: #d8e0e8; + --ui-text: #1f2d3d; + --ui-text-muted: #5d6d7e; + --ui-title: #1a2e44; + --ui-shadow-sm: 0 2px 8px rgba(15, 23, 42, 0.08); + --ui-shadow-md: 0 10px 24px rgba(15, 23, 42, 0.1); + --ui-radius: 12px; +} + +body { + font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif; + color: var(--ui-text); + background: var(--ui-bg); + margin: 0; + padding: 0; +} + +.container { + background-color: var(--ui-surface); + border: 0; + padding: 22px; + border-radius: var(--ui-radius); + box-shadow: none; + width: min(1200px, calc(100% - 28px)); + margin: 16px auto; +} + +.container-fluid { + padding-left: 16px; + padding-right: 16px; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--ui-title); + letter-spacing: 0.01em; +} + +h1, +h2 { + text-align: center; +} + +p, +small, +label, +span { + color: var(--ui-text-muted); +} + +.card, +.content, +.form-card, +.modal-content, +.list-group-item, +.dropdown-menu, +.table, +.alert { + border-radius: var(--ui-radius); +} + +.card, +.content, +.form-card, +.modal-content, +.list-group-item, +.dropdown-menu, +.table { + border: 0; + box-shadow: none; + background: var(--ui-surface); +} + +.card-header, +.modal-header, +thead th { + background: linear-gradient(180deg, #f7fafd, #edf3f8) !important; + border-bottom: 1px solid var(--ui-border) !important; + color: var(--ui-title) !important; +} + +.form-control, +.form-select, +textarea, +input[type="text"], +input[type="password"], +input[type="number"], +input[type="email"], +input[type="date"], +select { + border-radius: 10px !important; + border: 1px solid #cad5e0 !important; + min-height: 40px; + color: var(--ui-text) !important; + background-color: #fff !important; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-control:focus, +.form-select:focus, +textarea:focus, +input:focus, +select:focus { + border-color: #7ea3c8 !important; + box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.14) !important; + outline: 0; +} + +.table { + overflow: hidden; +} + +.table th, +.table td { + vertical-align: middle; + border-color: #dee6ee !important; +} + +.table-striped > tbody > tr:nth-of-type(odd) { + background-color: #f8fbff; +} + +.alert { + border-width: 1px; + box-shadow: none; +} + +.navbar { + border-bottom: 1px solid #273341; + box-shadow: none; +} + +.navbar-brand { + font-weight: 800; + letter-spacing: 0.02em; +} + +@media (max-width: 900px) { + .container { + width: calc(100% - 18px); + padding: 14px; + margin: 10px auto; + border-radius: 10px; + } +} + +/* 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 */ +} + +.ausleihen { + color: black !important; /* Override any existing color */ +} + +/* Modal image display fix */ +.modal-image { + display: none; + max-width: 100%; + max-height: 80vh; + margin: 0 auto; +} + +.modal-image.active-image { + display: block; +} + +/* Active image display classes */ +.item-image.active-image { + display: block !important; + opacity: 1 !important; + z-index: 5; +} + +.item-image:not(.active-image) { + display: none !important; +} + +/* QR Reader styling */ +#qr-reader { + width: 500px; + display: none; +} + +/* Edit new location container */ +.edit-new-location-container { + display: none; + margin-top: 10px; +} + +/* Modal dialog styling */ +.modal-dialog-white { + background: white; + padding: 20px; + border-radius: 5px; + text-align: center; +} + +.modal-content-margin { + margin-top: 10px; +} + +/* Unified list/card view switch styling for main and main_admin */ +.view-switch-group { + position: absolute; + right: 0; + top: 0; + display: flex; + gap: 8px; + padding: 4px; + border-radius: 999px; + background: transparent; + border: 0; + box-shadow: none; +} + +.view-toggle-btn { + width: 38px; + height: 38px; + border: 1px solid #c7ced6; + border-radius: 50%; + background: #ffffff; + font-size: 1.15rem; + cursor: pointer; + color: #2f3e4e; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.12s ease, box-shadow 0.18s ease, border-color 0.18s ease, background-color 0.18s ease; +} + +.view-toggle-btn:hover { + transform: translateY(-1px); + border-color: #8fa2b7; + background: #f8fbff; + box-shadow: 0 3px 8px rgba(33, 37, 41, 0.12); +} + +.view-toggle-btn:active { + transform: translateY(0); +} + +.view-toggle-btn[aria-pressed='true'] { + background: #e9f2ff; + border-color: #7ea1c8; + color: #1f4f7a; +} + +.table-view-header { + display: none; +} + +body.table-view .table-view-header { + display: grid; + grid-template-columns: var(--table-columns, minmax(180px, 2fr) repeat(5, minmax(110px, 1fr))); + gap: 10px 14px; + align-items: center; + padding: 12px 14px; + margin-bottom: 8px; + border: 0; + border-radius: 10px; + background: transparent; + color: #3f4e5d; + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +body.table-view .items-container { + display: block !important; + padding: 0; + overflow-x: visible; + scroll-snap-type: none; +} + +body.table-view .item-card { + width: 100%; + max-width: none; + margin: 0 0 10px 0; + padding: 12px 14px; + border: 1px solid #e1e6ed; + border-radius: 10px; + background: #ffffff; + box-shadow: none; + scroll-snap-align: none; +} + +body.table-view .item-card .card-content { + display: grid; + grid-template-columns: var(--table-columns, minmax(180px, 2fr) repeat(5, minmax(110px, 1fr))); + gap: 10px 14px; + align-items: center; +} + +body.table-view .item-card .card-content h3, +body.table-view .item-card .card-content p { + margin: 0; + max-height: none; +} + +body.table-view .item-card .card-content p strong { + display: none; +} + +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; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +body.table-view .item-card .card-content .item-ort-cell, +body.table-view .item-card .card-content .item-col-location, +body.table-view .item-card .card-content .item-filter1-cell, +body.table-view .item-card .card-content .item-col-filter1, +body.table-view .item-card .card-content .item-filter2-cell, +body.table-view .item-card .card-content .item-col-filter2, +body.table-view .item-card .card-content .item-filter3-cell, +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; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +body.table-view .item-card .image-container, +body.table-view .item-card .borrower-badge, +body.table-view .item-card .appointment-badge, +body.table-view .item-card .damage-badge { + display: none !important; +} + +body.table-view .item-card .actions { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed #d9e0e8; +} + +@media (max-width: 900px) { + .view-switch-group { + position: static; + margin: 8px auto 0; + width: fit-content; + } + + body.table-view .table-view-header { + display: none !important; + } + + body.table-view .item-card .card-content { + grid-template-columns: 1fr !important; + gap: 6px; + } + + body.table-view .item-card .card-content p strong { + display: inline; + } +} + +/* Unified action button styling for main and main_admin */ +:root { + --action-radius: 10px; + --action-shadow: none; + --action-shadow-hover: none; +} + +.actions, +.modal-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.actions form, +.modal-actions form { + margin: 0; +} + +.actions .ausleihen, +.actions .schedule-button, +.actions .quick-details-button, +.actions .edit-button, +.actions .duplicate-button, +.actions .delete-button, +.actions .damage-button, +.modal-actions .ausleihen, +.modal-actions .schedule-button, +.modal-actions .quick-details-button, +.modal-actions .edit-button, +.modal-actions .duplicate-button, +.modal-actions .delete-button, +.modal-actions .damage-button { + min-height: 38px !important; + padding: 8px 14px !important; + border-radius: var(--action-radius) !important; + border-width: 1px !important; + border-style: solid !important; + font-size: 0.88rem !important; + font-weight: 700 !important; + letter-spacing: 0.01em; + box-shadow: var(--action-shadow); + transition: transform 0.14s ease, box-shadow 0.14s ease, filter 0.14s ease; +} + +.actions .ausleihen, +.modal-actions .ausleihen { + background: #198754 !important; + border-color: #157347 !important; + color: #ffffff !important; +} + +.actions .schedule-button, +.modal-actions .schedule-button { + background: #fd7e14 !important; + border-color: #e46f10 !important; + color: #ffffff !important; +} + +.actions .quick-details-button, +.modal-actions .quick-details-button { + background: #6c757d !important; + border-color: #616971 !important; + color: #ffffff !important; +} + +.actions .edit-button, +.modal-actions .edit-button { + background: #0d6efd !important; + border-color: #0b5ed7 !important; + color: #ffffff !important; +} + +.actions .duplicate-button, +.modal-actions .duplicate-button { + background: #6f42c1 !important; + border-color: #6439af !important; + color: #ffffff !important; +} + +.actions .delete-button, +.modal-actions .delete-button { + background: #dc3545 !important; + border-color: #bb2d3b !important; + color: #ffffff !important; +} + +.actions .damage-button, +.modal-actions .damage-button { + background: #0dcaf0 !important; + border-color: #0aa5c0 !important; + color: #0b1f2a !important; +} + +.actions .disabled-button, +.modal-actions .disabled-button, +.actions button:disabled, +.modal-actions button:disabled { + opacity: 0.72; + filter: grayscale(0.28); + box-shadow: none; + cursor: not-allowed; +} + +.actions .ausleihen:hover, +.actions .schedule-button:hover, +.actions .quick-details-button:hover, +.actions .edit-button:hover, +.actions .duplicate-button:hover, +.actions .delete-button:hover, +.actions .damage-button:hover, +.modal-actions .ausleihen:hover, +.modal-actions .schedule-button:hover, +.modal-actions .quick-details-button:hover, +.modal-actions .edit-button:hover, +.modal-actions .duplicate-button:hover, +.modal-actions .delete-button:hover, +.modal-actions .damage-button:hover { + transform: translateY(-1px); + box-shadow: var(--action-shadow-hover); + filter: brightness(1.02); +} + +.actions button:active, +.modal-actions button:active { + transform: translateY(0); + box-shadow: var(--action-shadow); +} + +@media (max-width: 700px) { + .actions, + .modal-actions { + gap: 6px; + } + + .actions .ausleihen, + .actions .schedule-button, + .actions .quick-details-button, + .actions .edit-button, + .actions .duplicate-button, + .actions .delete-button, + .actions .damage-button, + .modal-actions .ausleihen, + .modal-actions .schedule-button, + .modal-actions .quick-details-button, + .modal-actions .edit-button, + .modal-actions .duplicate-button, + .modal-actions .delete-button, + .modal-actions .damage-button { + min-height: 36px !important; + padding: 7px 12px !important; + font-size: 0.84rem !important; + } +} + +/* Global uniform button system for all pages */ +:root { + --ui-btn-radius: 10px; + --ui-btn-min-height: 38px; + --ui-btn-padding-y: 8px; + --ui-btn-padding-x: 14px; + --ui-btn-font-size: 0.9rem; + --ui-btn-font-weight: 700; + --ui-btn-shadow: none; + --ui-btn-shadow-hover: none; +} + +.btn, +.action-button, +.button, +.export-button, +.search-button, +.scan-button, +.save-button, +.cancel-button, +.primary-button, +.danger-button, +.submit-button, +.nav-back-button, +.back-button, +.add-new-btn, +.fetch-isbn-button, +.import-book-button, +.return-button, +.item-reset-button, +.admin-only-button, +.reset-cancel-btn, +.reset-confirm-btn, +.btn-accept, +.btn-decline, +.popup-close-button, +.delete-image-button, +.remove-book-cover-button, +.remove-duplicate-image-button { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 6px; + min-height: var(--ui-btn-min-height) !important; + padding: var(--ui-btn-padding-y) var(--ui-btn-padding-x) !important; + border-radius: var(--ui-btn-radius) !important; + border: 1px solid transparent !important; + font-size: var(--ui-btn-font-size) !important; + font-weight: var(--ui-btn-font-weight) !important; + line-height: 1.2; + letter-spacing: 0.01em; + text-decoration: none !important; + cursor: pointer; + box-shadow: var(--ui-btn-shadow); + transition: transform 0.14s ease, box-shadow 0.14s ease, filter 0.14s ease; +} + +.btn:hover, +.action-button:hover, +.button:hover, +.export-button:hover, +.search-button:hover, +.scan-button:hover, +.save-button:hover, +.cancel-button:hover, +.primary-button:hover, +.danger-button:hover, +.submit-button:hover, +.nav-back-button:hover, +.back-button:hover, +.add-new-btn:hover, +.fetch-isbn-button:hover, +.import-book-button:hover, +.return-button:hover, +.item-reset-button:hover, +.admin-only-button:hover, +.reset-cancel-btn:hover, +.reset-confirm-btn:hover, +.btn-accept:hover, +.btn-decline:hover, +.popup-close-button:hover, +.delete-image-button:hover, +.remove-book-cover-button:hover, +.remove-duplicate-image-button:hover { + transform: translateY(-1px); + box-shadow: var(--ui-btn-shadow-hover); + filter: brightness(1.02); +} + +.btn:active, +.action-button:active, +.button:active, +.export-button:active, +.search-button:active, +.scan-button:active, +.save-button:active, +.cancel-button:active, +.primary-button:active, +.danger-button:active, +.submit-button:active, +.nav-back-button:active, +.back-button:active, +.add-new-btn:active, +.fetch-isbn-button:active, +.import-book-button:active, +.return-button:active, +.item-reset-button:active, +.admin-only-button:active, +.reset-cancel-btn:active, +.reset-confirm-btn:active, +.btn-accept:active, +.btn-decline:active, +.popup-close-button:active, +.delete-image-button:active, +.remove-book-cover-button:active, +.remove-duplicate-image-button:active { + transform: translateY(0); + box-shadow: var(--ui-btn-shadow); +} + +.btn:disabled, +.action-button:disabled, +.button:disabled, +.export-button:disabled, +.search-button:disabled, +.scan-button:disabled, +.save-button:disabled, +.cancel-button:disabled, +.primary-button:disabled, +.danger-button:disabled, +.submit-button:disabled, +.add-new-btn:disabled, +.fetch-isbn-button:disabled, +.import-book-button:disabled, +.return-button:disabled, +.item-reset-button:disabled, +.admin-only-button:disabled, +.reset-cancel-btn:disabled, +.reset-confirm-btn:disabled, +.btn-accept:disabled, +.btn-decline:disabled, +.popup-close-button:disabled, +.delete-image-button:disabled, +.remove-book-cover-button:disabled, +.remove-duplicate-image-button:disabled { + opacity: 0.72; + box-shadow: none; + cursor: not-allowed; + filter: grayscale(0.25); +} + +/* Preserve consistent role colors */ +.btn-primary, +.save-button, +.submit-button, +.primary-button, +.register-button { + background: #0d6efd !important; + border-color: #0b5ed7 !important; + color: #fff !important; +} + +.btn-secondary, +.cancel-button, +.button, +.nav-back-button, +.back-button, +.reset-cancel-btn { + background: #6c757d !important; + border-color: #616971 !important; + color: #fff !important; +} + +.btn-success, +.btn-accept, +.return-button, +.item-reset-button, +.import-book-button, +.fetch-isbn-button, +.scan-button { + background: #198754 !important; + border-color: #157347 !important; + color: #fff !important; +} + +.btn-warning, +.export-button, +.search-button, +.add-new-btn { + background: #fd7e14 !important; + border-color: #e46f10 !important; + color: #fff !important; +} + +.btn-danger, +.danger-button, +.btn-decline, +.reset-confirm-btn, +.delete-image-button, +.remove-book-cover-button, +.remove-duplicate-image-button { + background: #dc3545 !important; + border-color: #bb2d3b !important; + color: #fff !important; +} + +.btn-sm { + min-height: 32px !important; + padding: 6px 10px !important; + font-size: 0.82rem !important; +} + +.w-100.btn, +.w-100.action-button, +.w-100.button, +.w-100.export-button, +.w-100.save-button, +.w-100.submit-button, +.w-100.primary-button, +.w-100.danger-button, +.w-100.cancel-button { + width: 100% !important; +} + +@media (max-width: 700px) { + .btn, + .action-button, + .button, + .export-button, + .search-button, + .scan-button, + .save-button, + .cancel-button, + .primary-button, + .danger-button, + .submit-button, + .nav-back-button, + .back-button, + .add-new-btn, + .fetch-isbn-button, + .import-book-button, + .return-button, + .item-reset-button, + .admin-only-button, + .reset-cancel-btn, + .reset-confirm-btn, + .btn-accept, + .btn-decline, + .popup-close-button, + .delete-image-button, + .remove-book-cover-button, + .remove-duplicate-image-button { + min-height: 36px !important; + padding: 7px 12px !important; + font-size: 0.84rem !important; + } +} \ No newline at end of file diff --git a/Web/static/favicon.ico b/Web/static/favicon.ico new file mode 100755 index 0000000..12786f6 Binary files /dev/null and b/Web/static/favicon.ico differ diff --git a/Web/static/img/no-image.svg b/Web/static/img/no-image.svg new file mode 100644 index 0000000..4ce6ea7 --- /dev/null +++ b/Web/static/img/no-image.svg @@ -0,0 +1 @@ +No Image diff --git a/Web/static/js/ios_fixes.js b/Web/static/js/ios_fixes.js new file mode 100755 index 0000000..877ea7e --- /dev/null +++ b/Web/static/js/ios_fixes.js @@ -0,0 +1,400 @@ +/** + * Copyright 2025-2026 AIIrondev + * + * Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + * See Legal/LICENSE for the full license text. + * Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + * For commercial licensing inquiries: https://github.com/AIIrondev + */ + +// Initialize when document is loaded +document.addEventListener('DOMContentLoaded', function() { + if (isIOSDevice()) { + console.log('iOS device detected, applying iOS-specific enhancements'); + applyIOSFixes(); + } +}); + +// Apply all iOS-specific fixes +function applyIOSFixes() { + // Fix file input issues + fixIOSFileInputs(); + + // Fix form submission + fixIOSFormSubmission(); + + // Fix image handling + fixIOSImageHandling(); + + // Fix duplication + fixIOSDuplication(); + + // Apply CSS fixes + applyIOSCSSFixes(); +} + +// Fix iOS file input issues +function fixIOSFileInputs() { + // Find all file inputs + const fileInputs = document.querySelectorAll('input[type="file"]'); + + fileInputs.forEach(input => { + // Create a touch-friendly wrapper + const wrapper = document.createElement('div'); + wrapper.className = 'ios-file-input-wrapper'; + wrapper.style.position = 'relative'; + + // Style the original input to be more touch-friendly + input.style.padding = '20px 0'; + + // Wrap the input + if (input.parentNode) { + input.parentNode.insertBefore(wrapper, input); + wrapper.appendChild(input); + + // Add a visible button for better touch target + const fakeButton = document.createElement('div'); + fakeButton.className = 'ios-file-button'; + fakeButton.textContent = 'Wählen Sie Bilder/Videos aus'; + fakeButton.style.display = 'inline-block'; + fakeButton.style.padding = '10px 15px'; + fakeButton.style.backgroundColor = '#4CAF50'; + fakeButton.style.color = 'white'; + fakeButton.style.borderRadius = '4px'; + fakeButton.style.textAlign = 'center'; + fakeButton.style.margin = '10px 0'; + fakeButton.style.fontWeight = 'bold'; + wrapper.appendChild(fakeButton); + + // Make sure the real input covers the fake button + input.style.position = 'absolute'; + input.style.top = '0'; + input.style.left = '0'; + input.style.width = '100%'; + input.style.height = '100%'; + input.style.opacity = '0'; + input.style.zIndex = '10'; + } + + // Add custom event handling + input.addEventListener('change', function() { + // Update visual feedback + const fileCount = this.files ? this.files.length : 0; + let fileText = fakeButton.textContent; + + if (fileCount > 0) { + fileText = `${fileCount} Datei${fileCount !== 1 ? 'en' : ''} ausgewählt`; + fakeButton.style.backgroundColor = '#2E7D32'; + } else { + fileText = 'Wählen Sie Bilder/Videos aus'; + fakeButton.style.backgroundColor = '#4CAF50'; + } + + fakeButton.textContent = fileText; + }); + }); +} + +// Fix iOS form submission issues +function fixIOSFormSubmission() { + // Find upload forms + const uploadForms = document.querySelectorAll('form[action*="upload_item"]'); + + uploadForms.forEach(form => { + // Add an iOS submission handler + form.addEventListener('submit', function(e) { + // Only intercept on iOS + if (!isIOSDevice()) return; + + e.preventDefault(); + + // Show that we're processing + const loadingMessage = document.createElement('div'); + loadingMessage.className = 'ios-loading-message'; + loadingMessage.textContent = 'Wird verarbeitet...'; + loadingMessage.style.padding = '10px'; + loadingMessage.style.backgroundColor = '#f0f0f0'; + loadingMessage.style.borderRadius = '5px'; + loadingMessage.style.margin = '10px 0'; + loadingMessage.style.textAlign = 'center'; + form.appendChild(loadingMessage); + + // Disable the submit button + const submitButton = form.querySelector('button[type="submit"]'); + if (submitButton) { + submitButton.disabled = true; + } + + // Use timeout to allow UI to update before heavy processing + setTimeout(() => { + // Gather form data with special handling for iOS + const formData = new FormData(form); + + // Add iOS flag + formData.append('is_ios', 'true'); + + // Submit with fetch API which works better on iOS + fetch(form.action, { + method: 'POST', + body: formData, + credentials: 'same-origin' + }) + .then(response => response.json()) + .then(data => { + // Remove loading message + if (loadingMessage.parentNode) { + loadingMessage.parentNode.removeChild(loadingMessage); + } + + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + } + + if (data.success) { + // Show success and redirect + alert(data.message || 'Element erfolgreich hinzugefügt'); + + // Redirect to the appropriate page + if (data.itemId) { + window.location.href = `/home_admin?highlight_item=${data.itemId}`; + } else { + window.location.href = '/home_admin'; + } + } else { + // Show error + alert(data.message || 'Ein Fehler ist aufgetreten'); + } + }) + .catch(error => { + console.error('iOS form submission error:', error); + + // Remove loading message + if (loadingMessage.parentNode) { + loadingMessage.parentNode.removeChild(loadingMessage); + } + + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + } + + // Show error + alert('Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.'); + }); + }, 100); + }); + }); +} + +// Fix iOS image handling issues +function fixIOSImageHandling() { + // Reduce image preview quality on iOS + if (typeof ImagePreviewGenerator !== 'undefined') { + // Override the compression quality for iOS + const originalCompressImage = ImagePreviewGenerator.prototype._compressImage; + + if (originalCompressImage) { + ImagePreviewGenerator.prototype._compressImage = function(dataUrl) { + // Lower quality for iOS + this.quality = 0.5; + this.maxWidth = 600; + this.maxHeight = 600; + + return originalCompressImage.call(this, dataUrl); + }; + } + } + + // Fix image preview display + document.querySelectorAll('.image-preview-container').forEach(container => { + // Add iOS-specific styling + container.style.webkitOverflowScrolling = 'touch'; + container.style.maxHeight = '150px'; + container.style.overflow = 'auto'; + }); +} + +// Fix iOS duplication issues +function fixIOSDuplication() { + // Override duplicateItem function + if (typeof duplicateItem !== 'undefined') { + const originalDuplicateItem = duplicateItem; + + duplicateItem = function(itemId) { + // Use localStorage on iOS instead of sessionStorage + const useFunction = originalDuplicateItem; + + useFunction(itemId); + + // Additional iOS-specific handling + const checkForSessionData = setInterval(() => { + const sessionData = sessionStorage.getItem('duplicateItemData'); + if (sessionData) { + // Copy from sessionStorage to localStorage + try { + localStorage.setItem('duplicateItemData', sessionData); + localStorage.setItem('duplicateItemTimestamp', Date.now().toString()); + console.log('Copied duplicate data to localStorage for iOS reliability'); + } catch (e) { + console.warn('Failed to copy to localStorage:', e); + } + + clearInterval(checkForSessionData); + } + }, 100); + + // Clear interval after 3 seconds regardless + setTimeout(() => clearInterval(checkForSessionData), 3000); + }; + } + + // Fix prefill function + if (typeof prefillFormWithDuplicateData !== 'undefined') { + const originalPrefill = prefillFormWithDuplicateData; + + prefillFormWithDuplicateData = function() { + // Try to get data from localStorage first + let data = null; + try { + const localData = localStorage.getItem('duplicateItemData'); + if (localData) { + data = JSON.parse(localData); + localStorage.removeItem('duplicateItemData'); + localStorage.removeItem('duplicateItemTimestamp'); + console.log('Using duplicate data from localStorage'); + } + } catch (e) { + console.warn('Error accessing localStorage:', e); + } + + // If we got data from localStorage, manually populate the form + if (data) { + console.log('Manually populating form with iOS optimization'); + + // Basic fields + document.getElementById('name').value = data.name || ''; + document.getElementById('beschreibung').value = data.description || ''; + + // Handle filters with a delay + setTimeout(() => { + // Filter 1 + if (data.filter1 && Array.isArray(data.filter1)) { + data.filter1.forEach((val, idx) => { + const field = document.getElementById(`filter1-${idx+1}`); + if (field && val) field.value = val; + }); + } + + // Filter 2 + if (data.filter2 && Array.isArray(data.filter2)) { + data.filter2.forEach((val, idx) => { + const field = document.getElementById(`filter2-${idx+1}`); + if (field && val) field.value = val; + }); + } + + // Filter 3 + if (data.filter3 && Array.isArray(data.filter3)) { + data.filter3.forEach((val, idx) => { + const field = document.getElementById(`filter3-${idx+1}`); + if (field && val) field.value = val; + }); + } + }, 300); + + // Location + setTimeout(() => { + const location = document.getElementById('ort'); + if (location && data.location) location.value = data.location; + }, 400); + + // Year and cost + const year = document.getElementById('anschaffungsjahr'); + if (year && data.year) year.value = data.year; + + const cost = document.getElementById('anschaffungskosten'); + if (cost && data.cost) cost.value = data.cost; + + // Handle images - limited for iOS + const form = document.querySelector('form'); + if (form && data.images && Array.isArray(data.images)) { + // Add hidden field to indicate duplication + const isDuplicatingField = document.createElement('input'); + isDuplicatingField.type = 'hidden'; + isDuplicatingField.name = 'is_duplicating'; + isDuplicatingField.value = 'true'; + form.appendChild(isDuplicatingField); + + // Only use first 3 images on iOS for performance + const limitedImages = data.images.slice(0, 3); + + limitedImages.forEach(imageName => { + const imageField = document.createElement('input'); + imageField.type = 'hidden'; + imageField.name = 'duplicate_images'; + imageField.value = imageName; + form.appendChild(imageField); + }); + + // Add a message about limited images + if (data.images.length > 3) { + const messageDiv = document.createElement('div'); + messageDiv.style.margin = '10px 0'; + messageDiv.style.padding = '10px'; + messageDiv.style.backgroundColor = '#fff3cd'; + messageDiv.style.borderRadius = '5px'; + messageDiv.style.color = '#856404'; + messageDiv.textContent = `Aus Leistungsgründen werden nur ${limitedImages.length} von ${data.images.length} Bildern verwendet`; + + const fileInput = document.getElementById('images'); + if (fileInput && fileInput.parentNode) { + fileInput.parentNode.appendChild(messageDiv); + } else { + form.appendChild(messageDiv); + } + } + } + } else { + // Use original function as fallback + originalPrefill(); + } + }; + } +} + +// Apply iOS-specific CSS fixes +function applyIOSCSSFixes() { + // Create a style element + const style = document.createElement('style'); + style.textContent = ` + /* Improve touch targets for iOS */ + button, select, input[type="submit"], .ausleihen, .edit-button, + .delete-button, .duplicate-button, .details-button { + min-height: 44px !important; + padding: 10px 15px !important; + } + + /* Improve scrolling on iOS */ + .items-container, .filter-options, .modal-content { + -webkit-overflow-scrolling: touch !important; + } + + /* Prevent zoom on inputs */ + input, select, textarea { + font-size: 16px !important; + } + + /* Fix iOS image display */ + .item-image { + -webkit-transform: translateZ(0); + } + + /* Improve form fields on iOS */ + .form-group { + margin-bottom: 15px !important; + } + `; + + document.head.appendChild(style); +} diff --git a/Web/static/js/mobile_compatibility.js b/Web/static/js/mobile_compatibility.js new file mode 100755 index 0000000..67568ad --- /dev/null +++ b/Web/static/js/mobile_compatibility.js @@ -0,0 +1,285 @@ +/** + * Copyright 2025-2026 AIIrondev + * + * Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + * See Legal/LICENSE for the full license text. + * Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + * For commercial licensing inquiries: https://github.com/AIIrondev + */ + +// Detect mobile devices including tablets +function isMobileDevice() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} + +// Specifically detect iOS devices +function isIOSDevice() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; +} + +// Mobile-compatible file tracker to replace DataTransfer API +class MobileCompatFileTracker { + constructor() { + this.files = []; + this.removedIndices = new Set(); + } + + // Add files from a FileList + addFiles(fileList) { + // Convert FileList to Array for easier handling + Array.from(fileList).forEach(file => { + this.files.push(file); + }); + } + + // Remove a file by index + removeFile(index) { + if (index >= 0 && index < this.files.length) { + this.removedIndices.add(index); + } + } + + // Get all non-removed files + getFiles() { + return this.files.filter((_, index) => !this.removedIndices.has(index)); + } + + // Convert to FormData for upload + appendToFormData(formData, fieldName) { + this.getFiles().forEach(file => { + formData.append(fieldName, file); + }); + } + + // Clear all files + clear() { + this.files = []; + this.removedIndices.clear(); + } +} + +// Mobile optimized image preview generator +class ImagePreviewGenerator { + constructor(options = {}) { + this.maxWidth = options.maxWidth || 800; + this.maxHeight = options.maxHeight || 600; + this.quality = options.quality || 0.8; + this.maxPreviewsOnMobile = options.maxPreviewsOnMobile || 3; + } + + // Create optimized image preview element + createPreview(file, index) { + return new Promise((resolve, reject) => { + if (!file) { + reject(new Error('No file provided')); + return; + } + + const isVideo = file.type.startsWith('video/'); + + if (isVideo) { + this._createVideoPreview(file, index).then(resolve).catch(reject); + } else if (file.type.startsWith('image/')) { + this._createImagePreview(file, index).then(resolve).catch(reject); + } else { + reject(new Error('Unsupported file type')); + } + }); + } + + // Create image preview with optimization for mobile + _createImagePreview(file, index) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + // For mobile, we'll compress the image before creating the preview + if (isMobileDevice()) { + this._compressImage(e.target.result) + .then(compressedDataUrl => { + const previewElement = this._createPreviewElement(compressedDataUrl, file.name, index, false); + resolve(previewElement); + }) + .catch(err => { + // Fallback to original if compression fails + console.warn('Image compression failed, using original:', err); + const previewElement = this._createPreviewElement(e.target.result, file.name, index, false); + resolve(previewElement); + }); + } else { + // For desktop, use the original image + const previewElement = this._createPreviewElement(e.target.result, file.name, index, false); + resolve(previewElement); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + } + + // Create video preview with thumbnail generation + _createVideoPreview(file, index) { + return new Promise((resolve, reject) => { + const videoUrl = URL.createObjectURL(file); + const video = document.createElement('video'); + + video.src = videoUrl; + video.onloadedmetadata = () => { + // Create preview element + const previewElement = this._createPreviewElement(videoUrl, file.name, index, true); + resolve(previewElement); + + // Revoke object URL after a delay to ensure it's loaded + setTimeout(() => { + URL.revokeObjectURL(videoUrl); + }, 3000); + }; + + video.onerror = () => { + URL.revokeObjectURL(videoUrl); + reject(new Error('Failed to load video')); + }; + + // Set a timeout in case the video never loads + setTimeout(() => { + if (!video.videoWidth) { + URL.revokeObjectURL(videoUrl); + reject(new Error('Video load timeout')); + } + }, 5000); + }); + } + + // Create HTML element for preview + _createPreviewElement(src, fileName, index, isVideo) { + const previewDiv = document.createElement('div'); + previewDiv.className = 'image-preview-item'; + + if (isVideo) { + const video = document.createElement('video'); + video.src = src; + video.controls = true; + video.preload = 'metadata'; + video.style.maxWidth = '150px'; + video.style.maxHeight = '150px'; + video.style.objectFit = 'cover'; + previewDiv.appendChild(video); + } else { + const img = document.createElement('img'); + img.src = src; + img.alt = fileName; + img.style.maxWidth = '150px'; + img.style.maxHeight = '150px'; + img.style.objectFit = 'cover'; + previewDiv.appendChild(img); + } + + const controlsDiv = document.createElement('div'); + controlsDiv.className = 'image-controls'; + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.textContent = 'Entfernen'; + removeButton.style.background = '#dc3545'; + removeButton.style.color = 'white'; + removeButton.style.border = 'none'; + removeButton.style.padding = '5px 10px'; + removeButton.style.borderRadius = '3px'; + removeButton.style.cursor = 'pointer'; + removeButton.style.marginLeft = '10px'; + removeButton.dataset.index = index; + removeButton.addEventListener('click', function() { + const indexToRemove = parseInt(this.dataset.index, 10); + // We'll define removeImagePreview elsewhere + if (typeof window.removeImagePreview === 'function') { + window.removeImagePreview(indexToRemove); + } + }); + + controlsDiv.appendChild(removeButton); + previewDiv.appendChild(controlsDiv); + + return previewDiv; + } + + // Compress image for mobile devices + _compressImage(dataUrl) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + // Scale down if needed + if (width > this.maxWidth || height > this.maxHeight) { + const ratio = Math.min(this.maxWidth / width, this.maxHeight / height); + width *= ratio; + height *= ratio; + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + // Get compressed data URL + const compressedDataUrl = canvas.toDataURL('image/jpeg', this.quality); + + // Clean up + canvas.width = 0; + canvas.height = 0; + + resolve(compressedDataUrl); + }; + + img.onerror = () => reject(new Error('Failed to load image for compression')); + img.src = dataUrl; + }); + } +} + +// Mobile-optimized debounce function for event handlers +function debounce(func, wait = 300) { + let timeout; + return function(...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +} + +// Mobile-safe promise-based setTimeout +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Log mobile-specific issues to console or server +function logMobileIssue(action, error) { + if (isMobileDevice()) { + const diagnosticInfo = { + action: action, + error: error.message, + browser: navigator.userAgent, + timestamp: new Date().toISOString(), + memoryInfo: performance?.memory ? { + jsHeapSizeLimit: performance.memory.jsHeapSizeLimit, + totalJSHeapSize: performance.memory.totalJSHeapSize, + usedJSHeapSize: performance.memory.usedJSHeapSize + } : 'Not available' + }; + + console.error('Mobile Issue:', diagnosticInfo); + + // Optionally send to server + fetch('/log_mobile_issue', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(diagnosticInfo) + }).catch(() => { + // Silently fail if logging fails + }); + } +} diff --git a/Web/static/js/scripts.js b/Web/static/js/scripts.js new file mode 100755 index 0000000..d9e329b --- /dev/null +++ b/Web/static/js/scripts.js @@ -0,0 +1,24 @@ +/** + * Copyright 2025-2026 AIIrondev + * + * Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + * See Legal/LICENSE for the full license text. + * Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + * For commercial licensing inquiries: https://github.com/AIIrondev + */ +chatBox = document.getElementById('chatBox'); + +function fetchMessages() { + fetch('/messages') + .then(response => response.json()) + .then(data => { + chatBox.innerHTML = ''; + data.messages.forEach(msg => { + const messageElement = document.createElement('div'); + messageElement.textContent = msg; + chatBox.appendChild(messageElement); + }); + }); +} + +setInterval(fetchMessages, 1000); \ No newline at end of file diff --git a/Web/templates/admin_borrowings.html b/Web/templates/admin_borrowings.html new file mode 100644 index 0000000..100dc89 --- /dev/null +++ b/Web/templates/admin_borrowings.html @@ -0,0 +1,315 @@ + +{% extends 'base.html' %} +{% block content %} +
+

Ausleihen verwalten

+

Liste aller aktiven und geplanten Ausleihen. Sie können einzelne Einträge zurücksetzen oder bei Schäden eine Rechnung als PDF erstellen.

+ + {% if entries and entries|length > 0 %} + +
+

Filter

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + + + + + + {% endfor %} + +
StatusElementElement-IDBenutzerStartEndeStundePreisRechnungNotizenAktion
{{ e.status }}{{ e.item_name }}{{ e.item_id }}{{ e.user }}{{ e.start }}{{ e.end }}{{ e.period }}{{ e.item_cost }} + {% if e.invoice_number %} +
{{ e.invoice_number }}
+
{{ e.invoice_amount }}
+ {% if e.invoice_paid %} +
Bezahlt
+ {% if e.invoice_paid_at %} +
am {{ e.invoice_paid_at }}
+ {% endif %} + {% else %} +
Offen
+ {% endif %} + {% else %} +
Noch keine Rechnung
+ {% endif %} +
{{ e.notes }} +
+ + {% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %} +
+ +
+ {% endif %} +
+ +
+
+
+ + + + {% else %} +
+

Keine aktiven oder geplanten Ausleihen gefunden.

+
+ {% endif %} +
+ + + + +{% endblock %} diff --git a/Web/templates/base.html b/Web/templates/base.html new file mode 100755 index 0000000..23840a2 --- /dev/null +++ b/Web/templates/base.html @@ -0,0 +1,915 @@ + + + + + + + + + + {% block title %}Inventarsystem{% endblock %} + {% block head %} + + + + + + + + + + {% endblock %} + + + + {% if 'username' in session %} +
+ Module: +
+ + 📦 Inventarsystem + + {% if library_module_enabled %} +
+ + 📚 Bibliothek + + {% endif %} +
+
+ {% endif %} + + + + + + {% if library_module_enabled %} + + {% endif %} + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + + + + + + + + + + diff --git a/Web/templates/change_password.html b/Web/templates/change_password.html new file mode 100755 index 0000000..8c959c4 --- /dev/null +++ b/Web/templates/change_password.html @@ -0,0 +1,144 @@ + +{% extends "base.html" %} + +{% block title %}Passwort ändern - Inventarsystem{% endblock %} + +{% block content %} +
+
+

Passwort ändern

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + + Das Passwort sollte mindestens 6 Zeichen lang sein. +
+ +
+ + +
+ +
+ + Abbrechen +
+
+
+
+ + +{% endblock %} diff --git a/Web/templates/edit_item_functions.html b/Web/templates/edit_item_functions.html new file mode 100755 index 0000000..dbbfda1 --- /dev/null +++ b/Web/templates/edit_item_functions.html @@ -0,0 +1,288 @@ + + + diff --git a/Web/templates/impressum.html b/Web/templates/impressum.html new file mode 100755 index 0000000..8a3326e --- /dev/null +++ b/Web/templates/impressum.html @@ -0,0 +1,60 @@ + +{% extends "base.html" %} + +{% block title %}Impressum - Inventarsystem{% endblock %} + +{% block content %} +
+
+

Impressum

+

(Angaben gemäß § 5 TMG)

+ +
+

Kontaktinformationen

+

Name: . ..

+

Adresse: Musterstraße 123
12345 Musterstadt
Deutschland

+

E-Mail: kontakt@example.com

+

Telefon: +49 123 456789

+
+ +
+

Verantwortlich für den Inhalt

+

(nach § 55 Abs. 2 RStV)

+

...
+ Musterstraße 123
+ 12345 Musterstadt
+ Deutschland

+
+ +
+

Haftungsausschluss

+ +

Haftung für Inhalte

+

Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.

+ +

Haftung für Links

+

Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.

+
+ +
+

Urheberrecht

+

Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.

+

Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.

+
+ +
+

Datenschutz

+

Die Nutzung unserer Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben.

+

Wir weisen darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.

+

Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten durch Dritte zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit ausdrücklich widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-Mails, vor.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/Web/templates/library.html b/Web/templates/library.html new file mode 100644 index 0000000..5ba0ca8 --- /dev/null +++ b/Web/templates/library.html @@ -0,0 +1,488 @@ +{% extends "base.html" %} + +{% block title %}Bibliothek - {{ APP_VERSION }}{% endblock %} + +{% block content %} +
+ +
+
+

📚 Bibliothek

+

Bücher, CDs und weitere Medien

+
+ +
+ +
+ +
+ + +
+ +
+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + + + +
+ {% if library_items %} + {% for item in library_items %} +
+ +
+
+ {% if item.get('Image') %} + {{ item.Name }} + {% else %} +
📚
+ {% endif %} +
+ +
+

{{ item.Name }}

+ + {% if item.get('Author') %} +

Von: {{ item.Author }}

+ {% endif %} + + {% if item.get('ISBN') %} +

ISBN: {{ item.ISBN }}

+ {% endif %} + +
+ {{ item.get('ItemType', 'Medium') }} + + {% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %} + +
+
+
+ + + + + +
+ + {% if item.get('Verfuegbar') == True %} + + {% else %} + + {% endif %} +
+
+ {% endfor %} + {% else %} +
+

Keine Bibliotheks-Medien gefunden

+
+ {% endif %} +
+
+ + + + + + + + + + + +{% endblock %} diff --git a/Web/templates/library_borrowings_admin.html b/Web/templates/library_borrowings_admin.html new file mode 100644 index 0000000..293b3af --- /dev/null +++ b/Web/templates/library_borrowings_admin.html @@ -0,0 +1,590 @@ +{% extends "base.html" %} + +{% block title %}Bibliotheks-Ausleihen - {{ APP_VERSION }}{% endblock %} + +{% block content %} + + +
+
+
+

Bibliotheks-Ausleihen

+

Nur Bibliotheksmedien. Hier kannst du offene Ausleihen abschließen, Rechnungen bezahlen und defekte Medien direkt zurücksetzen.

+
+ +
+ +
+
+ Ausleihen +
{{ loan_entries|length }}
+
+
+ Defekte Medien +
{{ damaged_items|length }}
+
+
+ Direkt reparierbar +
{{ damaged_items|length }}
+
+
+ +
+ + + +
+ +
+

Bibliotheks-Ausleihen

+ {% if loan_entries %} + + + + + + + + + + + + + + {% for e in loan_entries %} + + + + + + + + + + {% endfor %} + +
StatusElementBenutzerZeitRechnungSchadenAktionen
+ {% if e.status == 'active' %} + Aktiv + {% elif e.status == 'planned' %} + Geplant + {% else %} + Abgeschlossen + {% endif %} + +
{{ e.item_name }}
+
{{ e.item_author or '—' }}
+
{{ e.item_code or '—' }}
+ +
+
{{ e.user }}
+
{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}
+
+
{{ e.period or '—' }}
+ {% if e.notes %}
{{ e.notes }}
{% endif %} +
+ {% if e.invoice_number %} +
{{ e.invoice_number }}
+
{{ e.invoice_amount }}
+ + {% if e.invoice_paid %} + Bezahlt + {% if e.invoice_paid_at %}
am {{ e.invoice_paid_at }}
{% endif %} + {% else %} + Offen + {% endif %} + {% else %} + Keine Rechnung + {% endif %} +
+ {% if e.has_damage %} + {{ e.damage_count }} Schaden{% if e.damage_count != 1 %}s{% endif %} + {% if e.damage_text %}
{{ e.damage_text }}
{% endif %} + {% else %} + Kein Schaden + {% endif %} +
+
+ {% if e.status == 'active' %} + + {% endif %} + {% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %} +
+ +
+ {% elif e.has_damage %} +
+ +
+ {% endif %} + + {% if e.status in ['active', 'planned'] %} +
+ +
+ {% endif %} +
+
+ + {% else %} +
Keine Bibliotheks-Ausleihen gefunden.
+ {% endif %} +
+ +
+

Defekte Bibliotheksmedien ohne aktive Ausleihe

+ {% if damaged_items %} + + + + + + + + + + + + {% for item in damaged_items %} + + + + + + + + {% endfor %} + +
ElementCodeSchadenStatusAktion
+
{{ item.name }}
+
{{ item.author or '—' }}
+
{{ item.isbn or '—' }}
+
{{ item.code or '—' }} + {{ item.damage_count }} Schaden{% if item.damage_count != 1 %}s{% endif %} + {% if item.damage_text %}
{{ item.damage_text }}
{% endif %} +
+ {% if item.available %} + Bereit zum Zurücksetzen + {% else %} + Nicht verfügbar + {% endif %} + {% if item.last_updated %}
{{ item.last_updated }}
{% endif %} +
+ +
+ +
+
+ + {% else %} +
Keine defekten Bibliotheksmedien ohne aktive Ausleihe gefunden.
+ {% endif %} +
+
+ + + + +{% endblock %} diff --git a/Web/templates/library_item_invoices.html b/Web/templates/library_item_invoices.html new file mode 100644 index 0000000..2407226 --- /dev/null +++ b/Web/templates/library_item_invoices.html @@ -0,0 +1,188 @@ +{% extends "base.html" %} + +{% block title %}Rechnungen Element - {{ APP_VERSION }}{% endblock %} + +{% block content %} + + +
+
+

Rechnungshistorie pro Element

+
+ Element: {{ item.name or '—' }} + Code: {{ item.code or '—' }} + Autor: {{ item.author or '—' }} + ISBN: {{ item.isbn or '—' }} +
+ +
+ +
+ {% if invoices %} + + + + + + + + + + + + + {% for row in invoices %} + + + + + + + + + {% endfor %} + +
RechnungBetragAusleiheStatusSchadenAktion
+
{{ row.invoice_number or '—' }}
+
erstellt {{ row.invoice_created_at or '—' }}
+ {% if row.invoice_created_by %}
von {{ row.invoice_created_by }}
{% endif %} +
{{ row.invoice_amount }} +
{{ row.borrow_user or '—' }}
+
{{ row.borrow_start or '' }}{% if row.borrow_end %} bis {{ row.borrow_end }}{% endif %}
+ {% if row.borrow_status == 'active' %} + Aktiv + {% elif row.borrow_status == 'planned' %} + Geplant + {% else %} + Abgeschlossen + {% endif %} +
+ {% if row.invoice_paid %} + Bezahlt + {% if row.invoice_paid_at %}
am {{ row.invoice_paid_at }}
{% endif %} + {% if row.invoice_paid_by %}
durch {{ row.invoice_paid_by }}
{% endif %} + {% else %} + Offen + {% endif %} +
+
{{ row.invoice_reason or 'Keine Beschreibung' }}
+
+ PDF öffnen +
+ {% else %} +
Für dieses Element wurden noch keine Rechnungen gespeichert.
+ {% endif %} +
+
+{% endblock %} diff --git a/Web/templates/library_table.html b/Web/templates/library_table.html new file mode 100644 index 0000000..ab7b8bd --- /dev/null +++ b/Web/templates/library_table.html @@ -0,0 +1,1076 @@ +{% extends "base.html" %} + +{% block title %}📚 Bibliothek - Tabellenansicht - {{ APP_VERSION }}{% endblock %} + +{% block content %} + + +
+ +
+

📚 Bibliothek

+

Bücher, CDs, DVDs und weitere Medien

+
+ + + + + + + +
+
+ + + + +
+
+ Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode. +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + +
TitelAutor/KünstlerISBN/CodeTypStatusAktionen
+
+ + + +
+ + + + + + + + +{% endblock %} diff --git a/Web/templates/license.html b/Web/templates/license.html new file mode 100755 index 0000000..f3b4fa0 --- /dev/null +++ b/Web/templates/license.html @@ -0,0 +1,394 @@ + +{% extends "base.html" %} + +{% block title %}License - Inventarsystem{% endblock %} + +{% block content %} +
+
+

Lizenzinformationen & Rechtliches

+ + + + +
+ + +
+
+ + +

Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen

+

Softwareprojekt: Inventarsystem
+ Urheberrechtshalter (Lizenzgeber): AIIrondev
+ Gültigkeit: Stand 2026

+ +
+ +

Präambel

+

Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag") stellt eine rechtsgültige Vereinbarung zwischen Ihnen + (im Folgenden „Lizenznehmer") und dem Urheber AIIrondev (im Folgenden „Lizenzgeber") dar. + Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software + „Inventarsystem" (im Folgenden „Produkt") erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen + vollumfänglich einverstanden.

+

Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung, + Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.

+ +
+ +

§ 1 Gegenstand der Lizenz und Eigentumsrechte

+
    +
  1. Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche + Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen + Kopien davon verbleiben ausschließlich beim Lizenzgeber.
  2. +
  3. Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag + explizit genannten Bedingungen.
  4. +
+ +

§ 2 Zulässiger Nutzungskreis (Privatnutzung)

+
    +
  1. Die unentgeltliche Nutzung des Produkts ist ausschließlich natürlichen Personen für den + rein privaten, häuslichen Gebrauch gestattet.
  2. +
  3. Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.
  4. +
  5. Jegliche Nutzung durch Institutionelle Nutzer (einschließlich, aber nicht + beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen, + Behörden oder NGOs) ist ausdrücklich untersagt und bedarf einer gesonderten, + schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.
  6. +
+ +

§ 3 Funktionale Einschränkungen und Support

+
    +
  1. Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is").
  2. +
  3. Für die kostenlose Privatnutzung besteht kein Anspruch auf: +
      +
    • Technischen Support oder Beratung.
    • +
    • Bereitstellung von Sicherheits-Updates oder Patches.
    • +
    • Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.
    • +
    +
  4. +
  5. Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern, + einzuschränken oder kostenpflichtig zu gestalten.
  6. +
+ +

§ 4 Wahrung der Urheberbezeichnung (Branding-Klausel)

+
    +
  1. Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung + „Powered by AIIrondev" in der Benutzeroberfläche (Footer/Menü).
  2. +
  3. Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder + deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder + Metadaten) zu beeinträchtigen.
  4. +
  5. Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der + Nutzungslizenz.
  6. +
+ +

§ 5 Verbot der kommerziellen Verwertung & SaaS

+
    +
  1. Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS), + insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt.
  2. +
  3. Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf + die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des + Lizenzgebers zulässig.
  4. +
+ +

§ 6 Modifikationen und Contributions

+
    +
  1. Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.
  2. +
  3. Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von + Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber + ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und + Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in + das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben.
  4. +
+ +

§ 7 Haftungsbeschränkung

+
    +
  1. Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des + Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn), + ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.
  2. +
  3. Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten + Daten.
  4. +
+ +

§ 8 Rechtswahl und Gerichtsstand

+
    +
  1. Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss + des UN-Kaufrechts (CISG).
  2. +
  3. Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der + Sitz des Lizenzgebers vereinbart.
  4. +
  5. Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen + Bestimmungen unberührt (Salvatorische Klausel).
  6. +
+ +
+ +
+

⚠️ Anfragen für Ausnahmegenehmigungen (Kommerzielle Lizenzen)

+

+ Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil: + AIIrondev auf GitHub +

+
+ +
+
+ + +
+
+

NOTICE – Urheberrechtliche Hinweise

+

Inventarsystem
Copyright © 2025-2026 AIIrondev

+

Dieses Projekt steht unter dem Inventarsystem EULA (Endbenutzer-Lizenzvertrag). Der vollständige Lizenztext + befindet sich in Legal/LICENSE. Die Software wird ausschließlich für den privaten, + nicht-kommerziellen Gebrauch bereitgestellt. Unerlaubte kommerzielle Nutzung, SaaS-Hosting oder das Entfernen + von Branding ist untersagt. Anfragen für kommerzielle Lizenzen: + https://github.com/AIIrondev

+ +
+

Drittanbieter-Hinweise

+

Dieses Projekt nutzt oder referenziert die folgende Drittanbieter-Software. Deren jeweilige Lizenzen gelten + wie angegeben. Dieser NOTICE-Abschnitt dient ausschließlich der Attribution und ändert keine Lizenzbedingungen.

+ +

Python-Pakete (Laufzeit)

+
    +
  • Flask (BSD-3-Clause) © Pallets
  • +
  • Werkzeug (BSD-3-Clause) © Pallets
  • +
  • Jinja2 (BSD-3-Clause) © Pallets (indirekt via Flask)
  • +
  • Click (BSD-3-Clause) © Pallets (indirekt via Flask)
  • +
  • Gunicorn (MIT) © 2009-2025 Benoit Chesneau and contributors
  • +
  • PyMongo (Apache-2.0) © MongoDB, Inc.
  • +
  • APScheduler (MIT) © 2009-2025 Alex Grönholm and contributors
  • +
  • Pillow (HPND/FreeType-style) © 1995-2011 Fredrik Lundh; © 2010-2025 Alex Clark and contributors
  • +
  • python-dateutil (BSD-3-Clause) © 2017-2025 dateutil team; © 2003-2011 Gustavo Niemeyer
  • +
  • pytz (MIT) © 2003-2025 Stuart Bishop
  • +
  • requests (Apache-2.0) © 2011-2025 Kenneth Reitz and contributors
  • +
  • qrcode (BSD-3-Clause) © 2010-2025 Lincoln Loop and contributors
  • +
+ +

Frontend / Laufzeit-Assets

+
    +
  • html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)
  • +
+ +

Systemkomponenten (nicht im Repository enthalten, ggf. im Deployment genutzt)

+
    +
  • NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors
  • +
  • FFmpeg (LGPL/GPL, je nach Konfiguration) © 2000-2025 FFmpeg developers
  • +
+ +
+
+
+ + +
+
+

Datenschutzerklärung (Privacy Policy)

+ +

1. Einleitung

+

Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Dieses Dokument erläutert, wie das + „Inventarsystem" personenbezogene Daten verarbeitet.

+ +

2. Verantwortliche Stelle

+

Verantwortlich für die Datenverarbeitung ist der Betreiber der jeweiligen Instanz (Self-Hosted). Bei Fragen + wenden Sie sich bitte an den Administrator Ihrer Installation.

+ +

3. Erhobene Daten

+

Das System verarbeitet folgende Kategorien von Daten:

+
    +
  • Benutzerdaten: Benutzername, (je nach Nutzer/Administrator Name und Nachname).
  • +
  • Inventardaten: Bezeichnungen von Gegenständen, Mengen, Standorte, ggf. Zuordnungen zu Personen.
  • +
  • Protokolldaten: Zeitstempel von Änderungen (Logs), IP-Adressen (serverseitig zur Sicherheit).
  • +
+ +

4. Zweck der Verarbeitung

+

Die Daten werden ausschließlich zu folgenden Zwecken verarbeitet:

+
    +
  • Verwaltung und Organisation von Beständen.
  • +
  • Sicherstellung der Systemsicherheit.
  • +
  • Nachvollziehbarkeit von Bestandsänderungen.
  • +
+ +

5. Rechtsgrundlage (DSGVO)

+

Die Verarbeitung erfolgt auf Basis von:

+
    +
  • Art. 6 Abs. 1 lit. b DSGVO: Erfüllung eines Vertrages oder vorvertraglicher Maßnahmen.
  • +
  • Art. 6 Abs. 1 lit. f DSGVO: Berechtigtes Interesse (effiziente Bestandsverwaltung).
  • +
+ +

6. Datenlöschung

+

Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind, + sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.

+
+
+ + +
+
+

Sicherheitsrichtlinie (Security Policy)

+ +

Unterstützte Versionen

+

Bitte nutzen Sie immer die neueste Version aus dem main-Branch, um Sicherheitsupdates zu erhalten.

+ +

Datenschutzrechtliche Mechanismen

+
    +
  • Passwort-Hashing: Passwörter werden niemals im Klartext gespeichert.
  • +
  • Session-Management: Automatisches Logout nach Inaktivität (konfigurierbar).
  • +
  • Berechtigungskonzept: Rollenbasierte Zugriffskontrolle (RBAC), um den Zugriff auf + „Need-to-know"-Basis zu beschränken.
  • +
+ +

Meldung von Sicherheitslücken

+
+

⚠️ Responsible Disclosure

+

Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte kein öffentliches Issue. + Kontaktieren Sie den Maintainer direkt über die + GitHub Security Advisory-Funktion + oder per E-Mail.

+
+
+
+ + +
+
+

Dokumentation der Datenverarbeitung (VVT)

+ +

Systemübersicht

+

Das Inventarsystem ist eine Anwendung zur Erfassung und Verwaltung von physischen Gütern.

+ +

Datenfluss

+
    +
  1. Eingabe: Nutzer gibt Daten über das Frontend/API ein.
  2. +
  3. Speicherung: Daten werden in einer lokalen Datenbank verschlüsselt abgelegt.
  4. +
  5. Ausgabe: Anzeige der Bestände für autorisierte Nutzer.
  6. +
+ +

Technische und Organisatorische Maßnahmen (TOM)

+
    +
  • Zugangskontrolle: Authentifizierung über Benutzerkonten.
  • +
  • Integrität: Validierung der Eingabedaten zur Vermeidung von Datenbankfehlern.
  • +
  • Verfügbarkeit: Empfohlene Backup-Strategien für die Datenbank.
  • +
+ +

Empfänger der Daten

+

Die Daten verbleiben innerhalb der kontrollierten Umgebung der Installation. Es findet kein automatisierter + Export an Dritte statt.

+
+
+ + + + +
+
+
+ + +{% endblock %} diff --git a/Web/templates/login.html b/Web/templates/login.html new file mode 100755 index 0000000..17e39cf --- /dev/null +++ b/Web/templates/login.html @@ -0,0 +1,126 @@ + +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+
+

Login

+
+
+ + +
+
+ +
+ + +
+
+ +
+
+
+ + + + +{% endblock %} diff --git a/Web/templates/logs.html b/Web/templates/logs.html new file mode 100755 index 0000000..7de9a17 --- /dev/null +++ b/Web/templates/logs.html @@ -0,0 +1,378 @@ + +{% extends "base.html" %} + +{% block title %}System Logs{% endblock %} + +{% block content %} +
+

System-Protokoll

+ + {% set conflicts = items | selectattr('ConflictDetected') | list %} + {% if conflicts %} +
+ ⚠ {{ conflicts | length }} Buchungskonflikt{{ 's' if conflicts | length > 1 else '' }} erkannt – + Geplante Reservierungen wurden aktiv, obwohl der Gegenstand bereits ausgeliehen war. + +
+ {% endif %} + +
+ + +
+ + + + + +
+
+ +
+ + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + {% endfor %} + +
Typ ↕Item Name ↕Benutzer ↕Status ↕Ausgeliehen am ↕Zurückgegeben am ↕DauerHinweis
{{ item.EventType or 'Ausleihe' }}{{ item.Item }}{{ item.User }} + {{ item.Status }} + {{ item.Start }}{{ item.End }}{{ item.Duration }} + {% if item.ConflictNote %} + {{ item.ConflictNote }} + {% endif %} + {% if item.ConflictDetected %} + ⚠ Konflikt + {% endif %} +
+
+ + +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/Web/templates/main.html b/Web/templates/main.html new file mode 100755 index 0000000..a1d417c --- /dev/null +++ b/Web/templates/main.html @@ -0,0 +1,4170 @@ +{% extends "base.html" %} + +{% block content %} + + +
+ + + +
+

Inventar Objekte +
+ + +
+

+
+
+
+ + + +
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+ + +
+ +
+ + + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/Web/templates/main_admin.html b/Web/templates/main_admin.html new file mode 100755 index 0000000..b4e5866 --- /dev/null +++ b/Web/templates/main_admin.html @@ -0,0 +1,4987 @@ + +{% extends "base.html" %} + +{% block title %}Inventarsystem{% endblock %} + +{% block content %} + + + + +
+ + + +
+

Inventar Objekte +
+ + +
+

+
Objekte im System: 0
+
+
+
+ + + +
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + + +
+ +
+ +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + + + + + + +{% include "edit_item_functions.html" %} + + +{% include "reset_item_functions.html" %} + + +{% include "reset_item_modal.html" %} + + +{% endblock %} diff --git a/Web/templates/manage_filters.html b/Web/templates/manage_filters.html new file mode 100755 index 0000000..62e17eb --- /dev/null +++ b/Web/templates/manage_filters.html @@ -0,0 +1,100 @@ + +{% extends "base.html" %} + +{% block title %}Filter verwalten{% endblock %} + +{% block content %} +
+

Filterwerte verwalten

+ +
+ +
+
+
+

Unterrichtsfach (Filter 1)

+
+
+
+
+ + +
+
+ +
Vorhandene Werte
+ {% if filter1_values %} +
+ {% for value in filter1_values %} +
+ {{ value }} +
+ +
+
+ {% endfor %} +
+ {% else %} +
Keine Werte definiert.
+ {% endif %} +
+
+
+ + +
+
+
+

Jahrgangsstufe (Filter 2)

+
+
+
+
+ + +
+
+ +
Vorhandene Werte
+ {% if filter2_values %} +
+ {% for value in filter2_values %} +
+ {{ value }} +
+ +
+
+ {% endfor %} +
+ {% else %} +
Keine Werte definiert.
+ {% endif %} +
+
+
+
+ +
+ Hinweis: Beim Entfernen eines Filterwertes wird dieser nicht aus bestehenden Objekten entfernt. + Bereits verwendete Werte bleiben in den Objekten erhalten. +
+ + +
+{% endblock %} diff --git a/Web/templates/manage_locations.html b/Web/templates/manage_locations.html new file mode 100755 index 0000000..7a00a29 --- /dev/null +++ b/Web/templates/manage_locations.html @@ -0,0 +1,200 @@ + +{% extends "base.html" %} + +{% block title %}Orte verwalten{% endblock %} + +{% block content %} +
+

Orte verwalten

+ +
+
+

Neuen Ort hinzufügen

+
+
+
+
+ + +
+ +
+
+
+ +
+
+

Vorhandene Orte

+
+
+ {% if location_values %} +
+ {% for value in location_values %} +
+ {{ value }} +
+ +
+
+ {% endfor %} +
+ {% else %} +
+ Es wurden noch keine Orte definiert. +
+ {% endif %} +
+
+ + +
+ + +{% endblock %} diff --git a/Web/templates/my_borrowed_items.html b/Web/templates/my_borrowed_items.html new file mode 100755 index 0000000..45db2d1 --- /dev/null +++ b/Web/templates/my_borrowed_items.html @@ -0,0 +1,642 @@ + +{% extends "base.html" %} + +{% block title %}Meine Ausleihungen{% endblock %} + +{% block content %} + + +
+
+

Meine aktuellen Ausleihungen

+ + {% if not items and not planned_items %} +
+ Sie haben derzeit keine Objekte ausgeliehen oder geplant. +
+ {% else %} + + {% if items %} +

Aktive Ausleihungen

+
+ {% for item in items %} +
+
+

{{ item.Name }}

+ {% if item.UserExemplarCount is defined and item.UserExemplarCount > 0 %} + {{ item.UserExemplarCount }} Exemplar(e) + {% endif %} + {% if item.ActiveAppointment %} + Aktiv + {% endif %} +
+ +
+
+

Ort: {{ item.Ort or '-' }}

+ + {% if item.Filter is defined %} +

Unterrichtsfach: + {% if item.Filter is string %} + {{ item.Filter }} + {% elif item.Filter | length > 0 %} + {{ item.Filter | join(', ') }} + {% else %} + - + {% endif %} +

+ {% endif %} + + {% if item.Filter2 is defined %} +

Jahrgangsstufe: + {% if item.Filter2 is string %} + {{ item.Filter2 }} + {% elif item.Filter2 | length > 0 %} + {{ item.Filter2 | join(', ') }} + {% else %} + - + {% endif %} +

+ {% endif %} + +

Barcode: {{ item.Code_4 or '-' }}

+ + {% if item.UserExemplars is defined and item.UserExemplars %} +
+

Ausgeliehene Exemplare:

+
    + {% for exemplar in item.UserExemplars %} +
  • + Exemplar {{ exemplar.number }} + {% if exemplar.date %}(seit {{ exemplar.date }}){% endif %} +
  • + {% endfor %} +
+
+ {% endif %} +
+ + {% if item.Images and item.Images | length > 0 %} +
+ {{ item.Name }} +
+ {% endif %} +
+ +
+ {% if item.AppointmentData is defined and item.AppointmentData %} + +
+
+ + +
+
+ +
+ + + {% if user_is_admin %} + + {% endif %} +
+ {% else %} + +
+
+ + {% if item.UserExemplarCount is defined and item.UserExemplarCount > 1 %} +
+ + +
+ {% endif %} + +
+ + + {% if user_is_admin %} + + {% else %} +
+ +
+ {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + {% if planned_items %} +

Geplante Ausleihungen

+
+ {% for item in planned_items %} +
+
+

{{ item.Name }}

+ {% set status = item.AppointmentData.status | default('planned') %} + + {% if status == 'planned' %} + Geplant + {% elif status == 'active' %} + Aktiv + {% elif status == 'completed' %} + Abgeschlossen + {% elif status == 'cancelled' %} + Storniert + {% else %} + {{ status }} + {% endif %} + +
+ +
+
+

Ort: {{ item.Ort or '-' }}

+ + {% if item.Filter is defined %} +

Unterrichtsfach: + {% if item.Filter is string %} + {{ item.Filter }} + {% elif item.Filter | length > 0 %} + {{ item.Filter | join(', ') }} + {% else %} + - + {% endif %} +

+ {% endif %} + + {% if item.Filter2 is defined %} +

Jahrgangsstufe: + {% if item.Filter2 is string %} + {{ item.Filter2 }} + {% elif item.Filter2 | length > 0 %} + {{ item.Filter2 | join(', ') }} + {% else %} + - + {% endif %} +

+ {% endif %} + +

Barcode: {{ item.Code_4 or '-' }}

+ +
+

Termin:

+ {% set start_date = item.AppointmentData.start %} + {% set end_date = item.AppointmentData.end %} +

+ {% if item.AppointmentData.period %} + {% if start_date %}{{ start_date.strftime('%d.%m.%Y') }}{% else %}Unbekannt{% endif %} + {{ item.AppointmentData.period }}. Stunde + {% else %} + {% if start_date and end_date and start_date.date() != end_date.date() %} + {{ start_date.strftime('%d.%m.%Y') }} - {{ end_date.strftime('%d.%m.%Y') }} + Mehrtägig + {% else %} + {% if start_date %}{{ start_date.strftime('%d.%m.%Y') }}{% else %}Unbekannt{% endif %} + {% endif %} + {% endif %} +

+ {% if item.AppointmentData.notes %} +

{{ item.AppointmentData.notes }}

+ {% endif %} +
+
+ + {% if item.Images and item.Images | length > 0 %} +
+ {{ item.Name }} +
+ {% endif %} +
+ +
+
+
+ +
+ + + {% if user_is_admin %} + + {% else %} +
+ +
+ {% endif %} +
+
+
+ {% endfor %} +
+ {% endif %} + + {% endif %} +
+
+ + +{% include "reset_item_functions.html" %} + + +{% include "reset_item_modal.html" %} + + + + +{% endblock %} diff --git a/Web/templates/register.html b/Web/templates/register.html new file mode 100755 index 0000000..175f229 --- /dev/null +++ b/Web/templates/register.html @@ -0,0 +1,338 @@ + +{% extends "base.html" %} + +{% block title %}Register{% endblock %} + +{% block content %} +
+
+

Neuen Benutzer registrieren

+

Erstellen Sie ein neues Benutzerkonto und legen Sie Zugriffsrechte fest

+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {% if category == 'success' %}✓{% else %}!{% endif %} + {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} +
+ +
+
+
+
+ +
+ 👤 + +
+ +
+ 👤 + +
+ +
+ 👤 + +
+
+ + + + {% if student_cards_module_enabled %} +
+ + +
+ {% endif %} + +
+ +
+
+
+
+
+ + + +{% if student_cards_module_enabled %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/Web/templates/reset_item_functions.html b/Web/templates/reset_item_functions.html new file mode 100755 index 0000000..db8d1be --- /dev/null +++ b/Web/templates/reset_item_functions.html @@ -0,0 +1,307 @@ + + + + diff --git a/Web/templates/reset_item_modal.html b/Web/templates/reset_item_modal.html new file mode 100755 index 0000000..07daa70 --- /dev/null +++ b/Web/templates/reset_item_modal.html @@ -0,0 +1,816 @@ + + + + + + + + + diff --git a/Web/templates/student_card_barcode_print.html b/Web/templates/student_card_barcode_print.html new file mode 100644 index 0000000..5c2b233 --- /dev/null +++ b/Web/templates/student_card_barcode_print.html @@ -0,0 +1,156 @@ + + + + + + + Schülerausweis - Barcode PDF Download + + + +
+
📇
+

Schülerausweis-Download

+

Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken

+ +
+ 📌 Was wird heruntergeladen? +

Eine druckfertige PDF mit:

+

✓ Alle Schülerausweise

+

✓ Scanbare CODE128 Barcodes

+

✓ Optimiert für A4-Druck (2 Karten pro Seite)

+

✓ Professionelle Qualität

+
+ + + +
+

Generiert am:

+
+
+ + + + diff --git a/Web/templates/student_cards_admin.html b/Web/templates/student_cards_admin.html new file mode 100644 index 0000000..a6e08e4 --- /dev/null +++ b/Web/templates/student_cards_admin.html @@ -0,0 +1,358 @@ + +{% extends "base.html" %} + +{% block title %}Schülerausweise - Inventarsystem{% endblock %} + +{% block content %} + + +
+
+
+

📚 Schülerausweise (Bibliotek)

+
+ +
+ + +
+

{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Schülerausweis{% endif %}

+ +
+ {% if edit_mode %} + + + {% else %} + + {% endif %} + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ {% if edit_mode %} + Abbrechen + {% endif %} + +
+
+
+ + +
+

Registrierte Ausweise

+ {% if student_cards %} + + + + + + + + + + + + + {% for card in student_cards %} + + + + + + + + + {% endfor %} + +
Ausweis-IDSchüler NameKlasseStandard AusleihdauerErstelltAktionen
{{ card.AusweisId }}{{ card.SchülerName }}{{ card.Klasse or '—' }}{{ card.StandardAusleihdauer }} Tage{{ card.Erstellt.strftime('%d.%m.%Y') if card.get('Erstellt') else '—' }} +
+
+ + +
+ 📥 PDF +
+ + + +
+
+
+ {% else %} +
+

Keine Schülerausweise registriert.

+

Fügen Sie ein neues Ausweis oben hinzu.

+
+ {% endif %} +
+
+ + + + +{% endblock %} + diff --git a/Web/templates/terminplan.html b/Web/templates/terminplan.html new file mode 100755 index 0000000..e99012f --- /dev/null +++ b/Web/templates/terminplan.html @@ -0,0 +1,1629 @@ + +{% extends "base.html" %} + +{% block title %}Terminplan - Inventarsystem{% endblock %} + +{% block content %} +
+
+

Schulstunden-Terminplan für Ausleihen

+
+ + + + + +
+
+ Aktuelle Ausleihungen + Geplante Ausleihungen + Abgeschlossene Ausleihungen + Ihre Ausleihungen +
+
+ +
+ +
+ +
+ + + + + + +
+ + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/Web/templates/tutorial.html b/Web/templates/tutorial.html new file mode 100644 index 0000000..a7dfe2e --- /dev/null +++ b/Web/templates/tutorial.html @@ -0,0 +1,445 @@ + +{% extends "base.html" %} + +{% block title %}Tutorial - {{ APP_VERSION }}{% endblock %} + +{% block content %} + + +
+
+

Kurzer Rundgang in 2-5 Minuten

+

Die Seite ist bewusst einfach gehalten. Sie begleitet Sie Schritt für Schritt, ohne viel Lesen und ohne komplizierte Fachbegriffe.

+
+ Dauer: ca. 4 Minuten + Format: Ruhig und geführt + Benutzer: {{ username }} +
+
+ +
+ + +
+ +
+

Startbereich

+

Hier starten Sie. Das ist der sichere Ausgangspunkt für alle weiteren Schritte.

+
    +
  • Sie sehen den Überblick.
  • +
  • Oben liegen die wichtigsten Wege.
  • +
  • Alles ist gross und einfach lesbar.
  • +
+ + +
+ +
+ {% if is_admin %} +

Artikel anlegen

+

Hier legen Sie einen neuen Artikel an. Es sind nur wenige Felder erforderlich.

+
    +
  • Name: Wie heisst der Artikel?
  • +
  • Ort: Wo soll er gelagert werden?
  • +
  • Beschreibung: Was ist daran interessant?
  • +
  • Code: Eine Nummer zum schnellen Finden.
  • +
+ + + {% else %} +

Artikel finden und ausleihen

+

Als Nutzerin/Nutzer arbeiten Sie hauptsaechlich mit vorhandenen Artikeln und Ausleihen.

+
    +
  • Nutzen Sie Filter und Suche, um Material schnell zu finden.
  • +
  • Öffnen Sie Details, um Ort, Beschreibung und Verfügbarkeit zu sehen.
  • +
  • Mit Ausleihen buchen Sie den Artikel direkt.
  • +
  • In Meine Ausleihen sehen Sie, was aktuell auf Ihren Namen laeuft.
  • +
+ + + {% endif %} +
+ + {% if library_module_enabled %} +
+

Bibliotek-Bereich

+

Dieser Bereich ist speziell für Bücher und Medienausleihe.

+
    + {% if is_admin %} +
  • Bücher hochladen: Neue Bücher in die Sammlung aufnehmen.
  • +
  • Ausleihen ansehen: Wer hat welches Buch ausgeliehen?
  • +
  • Defekte bearbeiten: Defekte Bücher markieren oder reparieren.
  • +
  • Rechnungen: Kosten und Rechnungen verwalten.
  • + {% else %} +
  • Medium suchen: Titel, Autor oder Barcode direkt finden.
  • +
  • Verfuegbarkeit pruefen: Schnell sehen, was aktuell ausleihbar ist.
  • +
  • Ausleihen: In der Bibliothek direkt auf Ihren Namen buchen.
  • +
  • Rückgabe-Status: Über "Meine Ausleihen" Fristen im Blick behalten.
  • + {% endif %} +
+ +
+ Zur Bibliotek + {% if is_admin %} + Ausleihen ansehen + {% endif %} +
+
+ {% endif %} + +
+

Tägliche Aufgaben

+

Das ist der gewöhnliche Arbeitsablauf während eines Schultages.

+ +
+

Morgens:

+

Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.

+
+ +
+

Mittags:

+

Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.

+
+ +
+

Nachmittags:

+

Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.

+
+ +
+ {% if is_admin %} + Admin-Bereich + Protokolle + {% else %} + Meine Ausleihen + {% endif %} +
+
+
+
+ +
+ Tipp: Sie können das Training jederzeit wieder öffnen und in Ruhe weiterklicken. +
+
+ + +{% endblock %} diff --git a/Web/templates/upload_admin.html b/Web/templates/upload_admin.html new file mode 100755 index 0000000..52d3338 --- /dev/null +++ b/Web/templates/upload_admin.html @@ -0,0 +1,1837 @@ + +{% extends "base.html" %} + +{% block title %}{{ page_title|default('Artikel hochladen') }} - Inventarsystem{% endblock %} + +{% block content %} +{% if duplicate_data and duplicate_data.images %} + + + +{% endif %} + + + +
+ ← Zurück zur Artikelübersicht + +
+

{{ page_title|default('Artikel hochladen') }}

+
+ +
+ + +
+
+ + + +
+ + + +
+
+
+ + +
+ + {% if show_library_features %} + +
+

Kategorie/Typ (customisierbar):

+
+ + Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung. +
+
+ {% else %} + +
+

Unterrichtsfach:

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Jahrgangsstufe:

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Thema:

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {% endif %} +
+ + +
+
+ + +
+
+ +
+ + +
+ Einzelcode manuell setzen oder per Scanner erfassen. + + +
+
+ + + Es werden eigene Items erstellt, aber in der Übersicht als ein Artikel gebündelt angezeigt. +
+
+ + + Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Scanner-Eingaben werden bei Mehrfachanzahl automatisch hier angefügt. +
+ +
+ + +
Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP
+ +
+
+ + + {% if show_library_features %} +
+ +
+ + + +
+ + Scannen oder manuell eingeben. Das Buchcover wird automatisch heruntergeladen. +
+
+ +
+ + + Wenn deaktiviert, kann der Artikel nicht im Voraus reserviert werden (Sofort-Ausleihe bleibt möglich). +
+ {% endif %} + + +
+
+
+ + + +{% endblock %} diff --git a/Web/templates/user_del.html b/Web/templates/user_del.html new file mode 100755 index 0000000..d6db060 --- /dev/null +++ b/Web/templates/user_del.html @@ -0,0 +1,290 @@ + +{% extends "base.html" %} + +{% block title %}Benutzer verwalten{% endblock %} + +{% block content %} +
+

Benutzer verwalten

+ +
+

Benutzer

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
BenutzernameVornameNachnameAdministratorAktionen
{{ user.username }}{{ user.name if user.name else user.username }}{{ user.last_name if user.last_name else '' }}{{ "Ja" if user.admin else "Nein" }} +
+ + +
+ + +
+
+
+
+ + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/Web/user.py b/Web/user.py new file mode 100755 index 0000000..2453202 --- /dev/null +++ b/Web/user.py @@ -0,0 +1,499 @@ +""" +Module for managing user accounts and authentication. +Provides methods for creating, validating, and retrieving user information. +""" +''' + Copyright 2025-2026 AIIrondev + + Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). + See Legal/LICENSE for the full license text. + Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. + For commercial licensing inquiries: https://github.com/AIIrondev +''' +from pymongo import MongoClient +import hashlib +from bson.objectid import ObjectId +import settings as cfg + + +def normalize_student_card_id(card_id): + """Normalize student card IDs for reliable lookup.""" + if card_id is None: + return '' + return str(card_id).strip().upper() + + +# === FAVORITES MANAGEMENT === +def get_favorites(username): + """Return a list of favorite item ObjectId strings for the user.""" + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + user = users.find_one({'Username': username}) or users.find_one({'username': username}) + client.close() + if not user: + return [] + favs = user.get('favorites', []) + # Normalize to strings + return [str(f) for f in favs if f] + +def add_favorite(username, item_id): + """Add an item to user's favorites (idempotent).""" + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + users.update_one( + {'$or': [{'Username': username}, {'username': username}]}, + {'$addToSet': {'favorites': ObjectId(item_id)}} + ) + client.close() + return True + except Exception: + return False + +def remove_favorite(username, item_id): + """Remove an item from user's favorites.""" + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + users.update_one( + {'$or': [{'Username': username}, {'username': username}]}, + {'$pull': {'favorites': ObjectId(item_id)}} + ) + client.close() + return True + except Exception: + return False + + + +def check_password_strength(password): + """ + Check if a password meets minimum security requirements. + + Args: + password (str): Password to check + + Returns: + bool: True if password is strong enough, False otherwise + """ + if len(password) < 6: + return False + return True + + +def hashing(password): + """ + Hash a password using SHA-512. + + Args: + password (str): Password to hash + + Returns: + str: Hexadecimal digest of the hashed password + """ + return hashlib.sha512(password.encode()).hexdigest() + + +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 + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + hashed_password = hashlib.sha512(password.encode()).hexdigest() + user = users.find_one({'Username': username, 'Password': hashed_password}) + client.close() + return user + + +def add_user(username, password, name, last_name, is_student=False, student_card_id=None, max_borrow_days=None): + """ + Add a new user to the database. + + Args: + username (str): Username for the new user + password (str): Password for the new user + + Returns: + bool: True if user was added successfully, False if password was too weak + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + if not check_password_strength(password): + return False + user_doc = { + 'Username': username, + 'Password': hashing(password), + 'Admin': False, + 'active_ausleihung': None, + 'name': name, + 'last_name': last_name, + 'IsStudent': bool(is_student) + } + + 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 + + +def student_card_exists(student_card_id): + """Return True if a student card id is already assigned to a user.""" + normalized = normalize_student_card_id(student_card_id) + if not normalized: + return False + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + exists = users.find_one({'StudentCardId': normalized}) is not None + client.close() + return exists + + +def get_user_by_student_card(student_card_id): + """Return user by student card id or None.""" + normalized = normalize_student_card_id(student_card_id) + if not normalized: + return None + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + found_user = users.find_one({'StudentCardId': normalized}) + client.close() + return found_user + + +def make_admin(username): + """ + Grant administrator privileges to a user. + + Args: + username (str): Username of the user to promote + + Returns: + bool: True if user was promoted successfully + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + users.update_one({'Username': username}, {'$set': {'Admin': True}}) + client.close() + return True + +def remove_admin(username): + """ + Remove administrator privileges from a user. + + Args: + username (str): Username of the user to demote + + Returns: + bool: True if user was demoted successfully + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + users.update_one({'Username': username}, {'$set': {'Admin': False}}) + client.close() + return True + +def get_user(username): + """ + Retrieve a specific user by username. + + Args: + username (str): Username to search for + + Returns: + dict: User document or None if not found + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + users_return = users.find_one({'Username': username}) + client.close() + return users_return + + +def check_admin(username): + """ + Check if a user has administrator privileges. + + Args: + username (str): Username to check + + Returns: + bool: True if user is an administrator, False otherwise + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + user = users.find_one({'Username': username}) + client.close() + return user and user.get('Admin', False) + + +def update_active_ausleihung(username, id_item, ausleihung): + """ + Update a user's active borrowing record. + + Args: + username (str): Username of the user + id_item (str): ID of the borrowed item + ausleihung (str): ID of the borrowing record + + Returns: + bool: True if successful + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + users.update_one({'Username': username}, {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}) + client.close() + return True + + +def get_active_ausleihung(username): + """ + Get a user's active borrowing record. + + Args: + username (str): Username of the user + + Returns: + dict: Active borrowing information or None + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + user = users.find_one({'Username': username}) + return user['active_ausleihung'] + + +def has_active_borrowing(username): + """ + Check if a user currently has an active borrowing. + + Args: + username (str): Username to check + + Returns: + bool: True if user has an active borrowing, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + + user = users.find_one({'username': username}) + if not user: + user = users.find_one({'Username': username}) + + if not user: + client.close() + return False + + has_active = user.get('active_borrowing', False) + + client.close() + return has_active + except Exception as e: + return False + + +def delete_user(username): + """ + Delete a user from the database. + Administrative function for removing user accounts. + + Args: + username (str): Username of the account to delete + + Returns: + bool: True if user was deleted successfully, False otherwise + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + result = users.delete_one({'username': username}) + client.close() + if result.deleted_count == 0: + # Try with different field name + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + result = users.delete_one({'Username': username}) + client.close() + + return result.deleted_count > 0 + + +def update_active_borrowing(username, item_id, status): + """ + Update a user's active borrowing status. + + Args: + username (str): Username of the user + item_id (str): ID of the borrowed item or None if returning + status (bool): True if borrowing, False if returning + + Returns: + bool: True if successful, False on error + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + result = users.update_one( + {'username': username}, + {'$set': { + 'active_borrowing': status, + 'borrowed_item': item_id if status else None + }} + ) + + if result.matched_count == 0: + result = users.update_one( + {'Username': username}, + {'$set': { + 'active_borrowing': status, + 'borrowed_item': item_id if status else None + }} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + return False + + +def get_name(username): + """ + Retrieve the name that is assosiated with the username. + + Returns: + str: String of name + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + user = users.find_one({'Username': username}) + name = user.get("name") + return name + + +def get_last_name(username): + """ + Retrieve the last_name that is assosiated with the username. + + Returns: + str: String of last_name + """ + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + user = users.find_one({'Username': username}) + name = user.get("last_name") + return name + + +def get_all_users(): + """ + Retrieve all users from the database. + Administrative function for user management. + + Returns: + list: List of all user documents + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + all_users = list(users.find()) + client.close() + return all_users + except Exception as e: + return [] + +def update_password(username, new_password): + """ + Update a user's password with a new one. + + Args: + username (str): Username of the user + new_password (str): New password to set + + Returns: + bool: True if password was updated successfully, False otherwise + """ + try: + if not check_password_strength(new_password): + return False + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + + # Hash the new password + hashed_password = hashing(new_password) + + # Update the user's password + result = users.update_one( + {'Username': username}, + {'$set': {'Password': hashed_password}} + ) + + client.close() + return result.modified_count > 0 + except Exception as e: + print(f"Error updating password: {e}") + return False + +def update_user_name(username, name, last_name): + """ + Update a user's name and last name. + + Args: + username (str): Username of the user + name (str): New first name + last_name (str): New last name + + Returns: + bool: True if updated successfully, False otherwise + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users = db['users'] + + result = users.update_one( + {'Username': username}, + {'$set': {'name': name, 'last_name': last_name}} + ) + + client.close() + return True + except Exception as e: + print(f"Error updating user name: {e}") + return False diff --git a/archive-logs.sh b/archive-logs.sh new file mode 100755 index 0000000..be7d6b9 --- /dev/null +++ b/archive-logs.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Monthly log archival script for Inventarsystem +# Runs on first day of month during automatic update +# Compresses logs to tar.gz, stores for up to 1 year, removes uncompressed originals + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_DIR="$PROJECT_DIR/logs" +ARCHIVE_BASE_DIR="$PROJECT_DIR/backups/logs_archive" +LOG_ARCHIVE_FILE="$LOG_DIR/archive-logs.log" +GUARD_FILE="$PROJECT_DIR/.last-log-archive" +COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}" + +# Ensure archive base directory exists +mkdir -p "$ARCHIVE_BASE_DIR" +chmod 755 "$ARCHIVE_BASE_DIR" 2>/dev/null || true + +log_message() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_ARCHIVE_FILE" +} + +should_archive_logs() { + local today month_day guard_date + + today="$(date +%Y-%m-%d)" + month_day="$(date +%d)" + + # Only run on first day of month + if [ "$month_day" != "01" ]; then + return 1 + fi + + # Check if we already ran today + if [ -f "$GUARD_FILE" ]; then + guard_date="$(cat "$GUARD_FILE" 2>/dev/null || echo '')" + if [ "$guard_date" = "$today" ]; then + log_message "Log archive already executed today ($today). Skipping." + return 1 + fi + fi + + return 0 +} + +archive_application_logs() { + local archive_name archive_path retention_days cleanup_count deleted_count + + if [ ! -d "$LOG_DIR" ] || [ -z "$(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' 2>/dev/null)" ]; then + log_message "No log files found in $LOG_DIR. Nothing to archive." + return 0 + fi + + archive_name="inventar_logs_$(date +%Y-%m-01_%H%M%S)" + archive_path="$ARCHIVE_BASE_DIR/${archive_name}.tar.gz" + + log_message "Starting monthly log archival..." + log_message "Archive destination: $archive_path" + + # Create temporary directory for prepping files + local tmp_prep_dir + tmp_prep_dir="$(mktemp -d)" + trap 'rm -rf "${tmp_prep_dir:-}"' RETURN + + # Copy log files to temp directory (excluding archive log itself during copy) + if ! find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' ! -name 'archive-logs.log' -exec cp {} "$tmp_prep_dir/" \; 2>/dev/null; then + log_message "WARNING: Could not copy all log files to temp directory" + fi + + # Create tar.gz archive + if tar -czf "$archive_path" -C "$tmp_prep_dir" . 2>/dev/null; then + log_message "Log archive created successfully: $archive_path" + log_message "Archive size: $(du -h "$archive_path" | cut -f1)" + else + log_message "ERROR: Failed to create log archive" + return 1 + fi + + # Remove uncompressed log files (keep archive-logs.log for continuity) + log_message "Removing uncompressed log files..." + local files_removed=0 + while IFS= read -r log_file; do + if [ "$log_file" != "$LOG_ARCHIVE_FILE" ]; then + rm -f "$log_file" && ((++files_removed)) || true + fi + done < <(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log') + log_message "Removed $files_removed uncompressed log file(s)" + + trap - RETURN +} + +cleanup_old_archives() { + local retention_days cleanup_count deleted_count total_archives + + retention_days="365" + + # Find and remove archives older than 1 year + log_message "Cleaning up archives older than $retention_days days..." + + total_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)" + deleted_count="0" + + find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' -mtime +$retention_days | while read -r old_archive; do + log_message "Removing old archive: $old_archive" + rm -f "$old_archive" + ((++deleted_count)) || true + done 2>/dev/null || true + + # Count deleted (safer approach) + local current_archives + current_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)" + deleted_count="$((total_archives - current_archives))" + + if [ "$deleted_count" -gt 0 ]; then + log_message "Deleted $deleted_count old archive(s); kept $current_archives" + else + log_message "No old archives to delete (total: $total_archives)" + fi +} + +main() { + if ! should_archive_logs; then + return 0 + fi + + log_message "=== Monthly log archival started ===" + + archive_application_logs || { + log_message "ERROR: Log archival failed" + return 1 + } + + cleanup_old_archives || { + log_message "WARNING: Archive cleanup encountered issues" + } + + # Update guard file with today's date + date +%Y-%m-%d > "$GUARD_FILE" + + log_message "=== Monthly log archival completed successfully ===" + return 0 +} + +main "$@" diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..4317e52 --- /dev/null +++ b/backup.sh @@ -0,0 +1,529 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ======================================================== +# Inventarsystem backup helper +# - Creates a dated backup folder/archive under /var/backups (by default) +# - Exports MongoDB via run-backup.sh into mongodb_backup/ +# - Cleans up backups older than KEEP_DAYS +# +# Options: +# --dest|--out|--backup-dir Destination base directory (default: /var/backups) +# --db|--db-name MongoDB database name (default: Inventarsystem) +# --uri|--mongo-uri MongoDB URI (default: mongodb://localhost:27017/) +# --invoice-archive-dir Invoice archive directory (default: /invoice-archive) +# --invoice-keep-days Retention for invoice archive in days (default: 3650) +# --log Log file (default: ./logs/backup.log) +# --no-compress Disable compression (keeps directory instead of .tar.gz) +# --compress-level <0-9> Compression level flag (only 0 is special here) +# --keep-days Age-based retention in days (default: 7; 0 disables age filter) +# --min-keep Always keep at least N most recent backups (default: 7) +# --mode Backup mode (default: auto) +# -h|--help Show help +# ======================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR}" +# Default destination now /var/backups; override with --dest or BACKUP_BASE_DIR env +BACKUP_BASE_DIR="${BACKUP_BASE_DIR:-/var/backups}" +LOG_DIR="$PROJECT_DIR/logs" +# Create only the log directory here; backup dir is created later with sudo if needed +mkdir -p "$LOG_DIR" +LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}" +COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}" +KEEP_DAYS="${KEEP_DAYS:-7}" +MIN_KEEP="${MIN_KEEP:-7}" +DB_NAME="${DB_NAME:-Inventarsystem}" +MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}" +INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}" +INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}" +BACKUP_MODE="${BACKUP_MODE:-auto}" +DOCKER_AVAILABLE=0 +DOCKER_COMPOSE_CMD=() +USE_NULL_OUTPUT=false + +usage() { + cat < Destination base directory (default: $BACKUP_BASE_DIR) + --db|--db-name MongoDB database name (default: $DB_NAME) + --uri|--mongo-uri MongoDB URI (default: $MONGO_URI) + --invoice-archive-dir Invoice archive directory (default: $INVOICE_ARCHIVE_DIR) + --invoice-keep-days Retention for invoice archive in days (default: $INVOICE_KEEP_DAYS) + --log Log file (default: $LOG_FILE) + --no-compress Disable compression (keeps directory instead of .tar.gz) + --compress-level <0-9> Compression level flag (only 0 is special here) + --keep-days Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter) + --min-keep Always keep at least this many backups (default: $MIN_KEEP) + --mode Backup mode (default: $BACKUP_MODE) + -h|--help Show this help and exit +EOF +} + +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --dest|--out|--backup-dir) + BACKUP_BASE_DIR="$2"; shift 2;; + --db|--db-name) + DB_NAME="$2"; shift 2;; + --uri|--mongo-uri) + MONGO_URI="$2"; shift 2;; + --invoice-archive-dir) + INVOICE_ARCHIVE_DIR="$2"; shift 2;; + --invoice-keep-days) + INVOICE_KEEP_DAYS="$2"; shift 2;; + --log) + LOG_FILE="$2"; shift 2;; + --no-compress) + COMPRESSION_LEVEL=0; shift;; + --compress-level) + COMPRESSION_LEVEL="${2:-6}"; shift 2;; + --keep-days) + KEEP_DAYS="$2"; shift 2;; + --min-keep) + MIN_KEEP="$2"; shift 2;; + --mode) + BACKUP_MODE="$2"; shift 2;; + -h|--help) + usage; exit 0;; + *) + echo "Unknown option: $1" >&2; usage; exit 2;; + esac +done + +# Function to log messages +log_message() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +init_docker_compose() { + if docker compose version >/dev/null 2>&1; then + DOCKER_AVAILABLE=1 + DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml") + return 0 + fi + if sudo docker compose version >/dev/null 2>&1; then + DOCKER_AVAILABLE=1 + DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml") + return 0 + fi + + DOCKER_AVAILABLE=0 + DOCKER_COMPOSE_CMD=() + return 1 +} + +compose_cmd() { + if [ "$DOCKER_AVAILABLE" -ne 1 ]; then + return 1 + fi + "${DOCKER_COMPOSE_CMD[@]}" "$@" +} + +try_backup_db_docker() { + local out_dir="$1" + local stamp + stamp="$(date +"%Y-%m-%d_%H-%M-%S")" + local archive_path="$out_dir/mongodb-${stamp}.archive.gz" + + [ "$DOCKER_AVAILABLE" -eq 1 ] || return 1 + + log_message "Attempting Docker-based MongoDB backup..." + compose_cmd up -d mongodb >/dev/null 2>&1 || return 1 + if ! compose_cmd ps --status running mongodb | grep -q mongodb; then + return 1 + fi + + if compose_cmd exec -T mongodb sh -c "mongodump --archive --gzip --db '$DB_NAME'" > "$archive_path"; then + log_message "Docker DB backup successful: $archive_path" + return 0 + fi + return 1 +} + +try_backup_invoices_docker() { + local archive_base="$1" + [ "$DOCKER_AVAILABLE" -eq 1 ] || return 1 + + local tmp_base + tmp_base="/tmp/$(basename "$archive_base")" + + log_message "Attempting Docker-based invoice archive export..." + compose_cmd up -d mongodb app >/dev/null 2>&1 || return 1 + if ! compose_cmd ps --status running app | grep -q app; then + return 1 + fi + + local app_cid + app_cid="$(compose_cmd ps -q app)" + [ -n "$app_cid" ] || return 1 + + if ! sudo docker cp "$PROJECT_DIR/Backup-Invoices.py" "$app_cid:/tmp/Backup-Invoices.py" >/dev/null 2>&1; then + return 1 + fi + + if ! compose_cmd exec -T app python /tmp/Backup-Invoices.py \ + --uri "mongodb://mongodb:27017/" \ + --db "$DB_NAME" \ + --out "$tmp_base" >/dev/null 2>&1; then + return 1 + fi + + sudo docker cp "$app_cid:${tmp_base}.jsonl" "${archive_base}.jsonl" >/dev/null 2>&1 || return 1 + sudo docker cp "$app_cid:${tmp_base}.csv" "${archive_base}.csv" >/dev/null 2>&1 || return 1 + sudo docker cp "$app_cid:${tmp_base}.meta.json" "${archive_base}.meta.json" >/dev/null 2>&1 || return 1 + + compose_cmd exec -T app sh -c "rm -f /tmp/Backup-Invoices.py ${tmp_base}.jsonl ${tmp_base}.csv ${tmp_base}.meta.json" >/dev/null 2>&1 || true + return 0 +} + +backup_invoices_archive() { + log_message "Starting invoice archive export..." + + # Ensure archive dir exists and is writable + if [ ! -d "$INVOICE_ARCHIVE_DIR" ]; then + sudo mkdir -p "$INVOICE_ARCHIVE_DIR" + sudo chmod 755 "$INVOICE_ARCHIVE_DIR" + fi + + local timestamp + timestamp="$(date +"%Y-%m-%d_%H-%M-%S")" + local archive_base + archive_base="$INVOICE_ARCHIVE_DIR/invoices-$timestamp" + + local py_exec + if [ -x "$PROJECT_DIR/.venv/bin/python" ]; then + py_exec="$PROJECT_DIR/.venv/bin/python" + else + py_exec="python3" + fi + + local host_invoice_ok=0 + local prefer_docker_invoice=0 + if [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then + prefer_docker_invoice=1 + log_message "Auto mode: preferring Docker invoice archive export" + fi + + if [ "$BACKUP_MODE" != "docker" ] && [ "$prefer_docker_invoice" -ne 1 ]; then + if [ "${USE_NULL_OUTPUT:-false}" = true ]; then + if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" > /dev/null 2>&1; then + host_invoice_ok=1 + fi + else + if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1; then + host_invoice_ok=1 + fi + fi + fi + + if [ "$host_invoice_ok" -ne 1 ]; then + if [ "$BACKUP_MODE" = "host" ]; then + log_message "ERROR: Failed to export invoice archive in host mode" + return 1 + fi + if ! try_backup_invoices_docker "$archive_base"; then + log_message "ERROR: Failed to export invoice archive" + return 1 + fi + fi + + sudo chmod 640 "$archive_base".jsonl "$archive_base".csv "$archive_base".meta.json 2>/dev/null || true + log_message "Invoice archive written: $archive_base.(jsonl|csv|meta.json)" + + # Retention cleanup for invoice archive (default: 10 years) + if [ "$INVOICE_KEEP_DAYS" -gt 0 ]; then + local deleted_count=0 + while IFS= read -r old_file; do + [ -z "$old_file" ] && continue + if sudo rm -f "$old_file"; then + deleted_count=$((deleted_count + 1)) + fi + done < <(find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \ + \( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) \ + -mtime +"$INVOICE_KEEP_DAYS" -print 2>/dev/null) + + log_message "Invoice archive cleanup complete (retention: $INVOICE_KEEP_DAYS days, deleted files: $deleted_count)" + fi + + return 0 +} + +# Function to create backup +create_backup() { + log_message "Starting backup process..." + + # Create date-formatted directory name + CURRENT_DATE=$(date +"%Y-%m-%d") + BACKUP_NAME="Inventarsystem-$CURRENT_DATE" + BACKUP_DIR="$BACKUP_BASE_DIR/$BACKUP_NAME" + BACKUP_ARCHIVE="$BACKUP_BASE_DIR/$BACKUP_NAME.tar.gz" + + # Create backup directory if it doesn't exist + if [ ! -d "$BACKUP_BASE_DIR" ]; then + sudo mkdir -p "$BACKUP_BASE_DIR" + sudo chmod 755 "$BACKUP_BASE_DIR" + fi + + # Remove existing backup with same date if it exists + if [ -d "$BACKUP_DIR" ]; then + log_message "Removing existing backup directory at $BACKUP_DIR" + sudo rm -rf "$BACKUP_DIR" + fi + + if [ -f "$BACKUP_ARCHIVE" ]; then + log_message "Removing existing backup archive at $BACKUP_ARCHIVE" + sudo rm -f "$BACKUP_ARCHIVE" + fi + + # Create a temporary directory for backup + log_message "Creating backup at $BACKUP_DIR" + sudo mkdir -p "$BACKUP_DIR" + # Copy project files excluding the backups directory itself (and optionally the venv) + if command -v rsync >/dev/null 2>&1; then + sudo rsync -a \ + --exclude='backups' \ + --exclude='.venv' \ + "$PROJECT_DIR"/ "$BACKUP_DIR"/ + else + # Fallback to cp while skipping the backups directory + for entry in "$PROJECT_DIR"/*; do + name=$(basename "$entry") + [[ "$name" == "backups" || "$name" == ".venv" ]] && continue + sudo cp -r "$entry" "$BACKUP_DIR/" + done + fi + + # Create database backup + log_message "Running database backup..." + # Create mongodb_backup directory with appropriate permissions first + sudo mkdir -p "$BACKUP_DIR/mongodb_backup" + sudo chmod 755 "$BACKUP_DIR/mongodb_backup" + + # Run database backup with our helper script + log_message "Executing database backup script..." + db_backup_ok=0 + use_host_db_backup=1 + if [ "$BACKUP_MODE" = "docker" ]; then + use_host_db_backup=0 + elif [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then + use_host_db_backup=0 + log_message "Auto mode: preferring Docker DB backup" + fi + + if [ "$use_host_db_backup" -eq 1 ]; then + # Create Backup_db.log and ensure it's writable only when host mode is used + touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null + chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || { + log_message "WARNING: Failed to set permissions on Backup_db.log. Trying with sudo..." + sudo touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null + sudo chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || { + log_message "WARNING: Failed to create writable Backup_db.log. Redirecting output to /dev/null instead." + USE_NULL_OUTPUT=true + } + } + + # Install pymongo only for host backups + log_message "Checking pymongo installation..." + if ! python3 -c "import pymongo" &>/dev/null; then + log_message "Installing pymongo..." + if [ -f "$PROJECT_DIR/.venv/bin/pip" ]; then + if [ "$USE_NULL_OUTPUT" = true ]; then + "$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 > /dev/null 2>&1 || true + else + "$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true + fi + fi + if ! python3 -c "import pymongo" &>/dev/null; then + if [ "$USE_NULL_OUTPUT" = true ]; then + pip3 install pymongo==4.6.3 > /dev/null 2>&1 || true + else + pip3 install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true + fi + fi + fi + + if [ "${USE_NULL_OUTPUT:-false}" = true ]; then + sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" > /dev/null 2>&1 && db_backup_ok=1 || { + log_message "ERROR: Failed to backup database with original path" + + # Try an alternative approach - use a temporary directory + log_message "Attempting backup via temporary directory..." + tmp_backup_dir="/tmp/mongodb_backup_$$" + mkdir -p "$tmp_backup_dir" + + "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" > /dev/null 2>&1 && { + # Copy temporary backup files to the actual backup directory + log_message "Copying backup files from temporary directory..." + cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/" + db_backup_ok=1 + } || { + log_message "ERROR: All attempts to backup database failed" + } + } + else + sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && db_backup_ok=1 || { + log_message "ERROR: Failed to backup database with original path" + + # Try an alternative approach - use a temporary directory + log_message "Attempting backup via temporary directory..." + tmp_backup_dir="/tmp/mongodb_backup_$$" + mkdir -p "$tmp_backup_dir" + + "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && { + # Copy temporary backup files to the actual backup directory + log_message "Copying backup files from temporary directory..." + cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/" + db_backup_ok=1 + } || { + log_message "ERROR: All attempts to backup database failed" + } + } + fi + else + log_message "Docker mode selected: skipping host DB backup step" + fi + + # Auto-fallback for docker-first environments + if [ "$db_backup_ok" -ne 1 ] && [ "$BACKUP_MODE" != "host" ]; then + if try_backup_db_docker "$BACKUP_DIR/mongodb_backup"; then + db_backup_ok=1 + fi + fi + + # Check if any CSV files were created during backup + if ! find "$BACKUP_DIR/mongodb_backup" -name "*.csv" -quit; then + log_message "WARNING: No CSV files found in backup directory" + + # If we used a temporary directory earlier and it still exists, try to use its contents + if [ -d "$tmp_backup_dir" ]; then + log_message "Backup created in temporary directory, moving to backup location..." + sudo cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/" + rm -rf "$tmp_backup_dir" + fi + fi + + # Reset to more restrictive permissions after backup completes + sudo chmod -R 755 "$BACKUP_DIR/mongodb_backup" + + # Verify that backup files were created + csv_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.csv" 2>/dev/null | wc -l) + archive_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.archive.gz" 2>/dev/null | wc -l) + if [ "$csv_count" -gt 0 ] || [ "$archive_count" -gt 0 ]; then + log_message "Database backup successful: ${csv_count} CSV collection(s), ${archive_count} archive file(s)" + else + log_message "WARNING: No database backup files found in backup directory" + fi + + # Export legal invoice archive separately with long retention + backup_invoices_archive || log_message "WARNING: Invoice archive export failed" + + # Compress the backup + if [ "$COMPRESSION_LEVEL" -gt 0 ]; then + log_message "Compressing backup with level $COMPRESSION_LEVEL..." + + # Create compressed archive with pigz when available, otherwise gzip. + if command -v pigz >/dev/null 2>&1; then + gzip_cmd="pigz -${COMPRESSION_LEVEL}" + else + gzip_cmd="gzip -${COMPRESSION_LEVEL}" + fi + if tar --help 2>/dev/null | grep -q -- "--use-compress-program"; then + sudo tar -cf "$BACKUP_ARCHIVE" -I "$gzip_cmd" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || { + log_message "ERROR: Failed to compress backup (tar -I)" + return 1 + } + else + GZIP="-${COMPRESSION_LEVEL}" sudo -E tar -czf "$BACKUP_ARCHIVE" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || { + log_message "ERROR: Failed to compress backup (tar + GZIP env)" + return 1 + } + fi + + # Remove uncompressed directory after successful compression + if [ -f "$BACKUP_ARCHIVE" ]; then + log_message "Backup compressed successfully to $BACKUP_ARCHIVE" + log_message "Removing uncompressed backup directory" + sudo rm -rf "$BACKUP_DIR" + else + log_message "ERROR: Compressed backup file not found" + return 1 + fi + else + log_message "Compression disabled, keeping uncompressed backup" + fi + + # Set appropriate permissions + if [ "$COMPRESSION_LEVEL" -gt 0 ]; then + sudo chmod 644 "$BACKUP_ARCHIVE" + else + sudo chmod -R 755 "$BACKUP_DIR" + fi + + # Clean up old backups: age-based filter plus minimum keep safeguard + log_message "Cleaning up old backups (keep at least $MIN_KEEP; days>$KEEP_DAYS)..." + # Build list of all backup artifacts (files and directories) sorted newest first + mapfile -t ALL_BACKUPS < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \ + \( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \ + -printf '%T@\t%p\n' 2>/dev/null | sort -nr | awk -F '\t' '{print $2}') + TOTAL=${#ALL_BACKUPS[@]} + if (( TOTAL > MIN_KEEP )); then + # Determine deletion candidates by age (if KEEP_DAYS>0), otherwise all except the newest MIN_KEEP + if (( KEEP_DAYS > 0 )); then + mapfile -t AGE_CANDIDATES < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \ + \( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \ + -mtime +"$KEEP_DAYS" -printf '%T@\t%p\n' 2>/dev/null | sort -n | awk -F '\t' '{print $2}') + else + AGE_CANDIDATES=() + # If no age filter, consider everything except the newest MIN_KEEP as candidates (oldest first) + if (( TOTAL > MIN_KEEP )); then + # Reverse ALL_BACKUPS to get oldest first + for ((i=TOTAL-1; i>=MIN_KEEP; i--)); do AGE_CANDIDATES+=("${ALL_BACKUPS[$i]}"); done + fi + fi + + # Protect the newest MIN_KEEP backups overall + ALLOWED_DELETE=$(( TOTAL - MIN_KEEP )) + DELETED=0 + for path in "${AGE_CANDIDATES[@]:-}"; do + (( DELETED >= ALLOWED_DELETE )) && break + if [ -z "$path" ]; then continue; fi + if [ -f "$path" ]; then + sudo rm -f "$path" && ((DELETED++)) + elif [ -d "$path" ]; then + sudo rm -rf "$path" && ((DELETED++)) + fi + done + log_message "Deleted $DELETED old backup(s); kept $(( TOTAL - DELETED ))" + else + log_message "No cleanup needed (total backups: $TOTAL <= min-keep: $MIN_KEEP)" + fi + + log_message "Backup completed successfully" + return 0 +} + +# Main +log_message "Backup destination: $BACKUP_BASE_DIR" +log_message "Database: $DB_NAME | URI: $MONGO_URI | Keep days: $KEEP_DAYS | Min keep: $MIN_KEEP | Compression: ${COMPRESSION_LEVEL}" +log_message "Invoice archive: $INVOICE_ARCHIVE_DIR | Invoice retention days: $INVOICE_KEEP_DAYS" +log_message "Backup mode: $BACKUP_MODE" + +if [[ "$BACKUP_MODE" != "auto" && "$BACKUP_MODE" != "host" && "$BACKUP_MODE" != "docker" ]]; then + log_message "ERROR: Invalid --mode value '$BACKUP_MODE' (allowed: auto, host, docker)" + exit 2 +fi + +init_docker_compose || true + +if create_backup; then + ART_DATE="$(date +"%Y-%m-%d")" + if [[ "$COMPRESSION_LEVEL" -gt 0 ]]; then + echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE.tar.gz" + else + echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE" + fi +fi \ No newline at end of file diff --git a/build-nuitka.sh b/build-nuitka.sh new file mode 100755 index 0000000..6417a2e --- /dev/null +++ b/build-nuitka.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +SUDO="" +if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" +fi + +VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python" +if [ ! -x "$VENV_PYTHON" ]; then + echo "Error: .venv python not found at $VENV_PYTHON" + echo "Create it first, e.g.: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt" + exit 1 +fi + +if [ "$(id -u)" -ne 0 ] && [ ! -w "$SCRIPT_DIR/.venv" ]; then + echo "Fixing ownership of .venv for current user..." + if [ -n "$SUDO" ]; then + $SUDO chown -R "$(id -un):$(id -gn)" "$SCRIPT_DIR/.venv" + else + echo "Error: .venv is not writable and sudo is unavailable." + exit 1 + fi +fi + +for path in "$SCRIPT_DIR/dist" "$SCRIPT_DIR/build"; do + if [ -e "$path" ] && [ "$(id -u)" -ne 0 ]; then + echo "Ensuring ownership of $(basename "$path") for current user..." + if [ -n "$SUDO" ]; then + $SUDO chown -R "$(id -un):$(id -gn)" "$path" + else + if [ ! -w "$path" ]; then + echo "Error: $path is not writable and sudo is unavailable." + exit 1 + fi + fi + fi +done + +echo "Installing Nuitka build dependencies in .venv..." +"$VENV_PYTHON" -m pip install --upgrade pip setuptools wheel +"$VENV_PYTHON" -m pip install --upgrade "nuitka==2.8.10" ordered-set zstandard + +DIST_DIR="$SCRIPT_DIR/dist" +BUILD_DIR="$SCRIPT_DIR/build" +OUTPUT_NAME="inventarsystem" + +mkdir -p "$DIST_DIR" "$BUILD_DIR" + +NUITKA_DATA_ARGS=() + +if [ -d "$SCRIPT_DIR/Web/templates" ]; then + NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/templates=templates") +fi + +if [ -d "$SCRIPT_DIR/Web/static" ]; then + NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/static=static") +fi + +if [ -d "$SCRIPT_DIR/uploads" ]; then + NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/uploads=uploads") +fi + +ORIGINAL_PERF_PARANOID="" +if [ -r /proc/sys/kernel/perf_event_paranoid ]; then + ORIGINAL_PERF_PARANOID="$(cat /proc/sys/kernel/perf_event_paranoid)" + if [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then + echo "Temporarily setting kernel.perf_event_paranoid=1 for Nuitka build compatibility..." + if ! $SUDO sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid'; then + echo "Warning: Could not adjust kernel.perf_event_paranoid automatically." + echo "Run: sudo sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid' and retry." + exit 1 + fi + fi +fi + +restore_perf_setting() { + if [ -n "$ORIGINAL_PERF_PARANOID" ] && [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then + $SUDO sh -c "echo $ORIGINAL_PERF_PARANOID > /proc/sys/kernel/perf_event_paranoid" >/dev/null 2>&1 || true + fi +} + +trap restore_perf_setting EXIT + +echo "Building standalone binary with Nuitka..." +"$VENV_PYTHON" -m nuitka \ + --standalone \ + --assume-yes-for-downloads \ + --follow-imports \ + --output-dir="$DIST_DIR" \ + --output-filename="$OUTPUT_NAME" \ + "${NUITKA_DATA_ARGS[@]}" \ + --remove-output \ + "$SCRIPT_DIR/Web/app.py" + +APP_DIST_DIR="$DIST_DIR/app.dist" +if [ -d "$APP_DIST_DIR" ]; then + echo "Nuitka build complete." + echo "Run with: $APP_DIST_DIR/$OUTPUT_NAME" +else + echo "Build finished, but expected output directory not found: $APP_DIST_DIR" + echo "Check Nuitka output above." + exit 1 +fi diff --git a/cleanup-old.sh b/cleanup-old.sh new file mode 100755 index 0000000..780970e --- /dev/null +++ b/cleanup-old.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR" +ADMIN_PROJECT_DIR="${ADMIN_PROJECT_DIR:-$(dirname "$PROJECT_DIR")/admin_Inventarsystem}" + +DRY_RUN=0 +REMOVE_CRON=0 +KEEP_AUTOSTART=0 + +SERVICES=( + "inventarsystem-gunicorn.service" + "admin-inventarsystem-gunicorn.service" + "admin-inventarsystem-nginx.service" +) + +SOCKETS=( + "/tmp/inventarsystem.sock" + "/tmp/admin-inventarsystem.sock" +) + +PROCESS_PATTERNS=( + "$PROJECT_DIR/.venv/bin/gunicorn app:app" + "$ADMIN_PROJECT_DIR/.venv/bin/gunicorn app:app" +) + +CRON_FILTER_PATTERNS=( + "$PROJECT_DIR/update.sh" + "$PROJECT_DIR/backup-docker.sh" + "$ADMIN_PROJECT_DIR/update.sh" + "$ADMIN_PROJECT_DIR/backup-docker.sh" +) + +usage() { + cat </dev/null | awk '{print $1}' | grep -Fxq "$service" +} + +stop_services() { + if ! command -v systemctl >/dev/null 2>&1; then + log "systemctl not available, skipping service cleanup" + return 0 + fi + + local service + for service in "${SERVICES[@]}"; do + if ! service_exists "$service"; then + log "Service not found: $service" + continue + fi + + log "Stopping $service" + run_cmd_sudo systemctl stop "$service" || true + + if [ "$KEEP_AUTOSTART" -eq 0 ]; then + log "Disabling autostart for $service" + run_cmd_sudo systemctl disable "$service" || true + fi + done +} + +kill_leftover_processes() { + local pattern + for pattern in "${PROCESS_PATTERNS[@]}"; do + log "Terminating processes matching: $pattern" + run_cmd_sudo pkill -TERM -f "$pattern" || true + done + + if [ "$DRY_RUN" -eq 0 ]; then + sleep 2 + fi + + for pattern in "${PROCESS_PATTERNS[@]}"; do + run_cmd_sudo pkill -KILL -f "$pattern" || true + done +} + +remove_stale_sockets() { + local socket_path + for socket_path in "${SOCKETS[@]}"; do + if [ -e "$socket_path" ]; then + log "Removing stale socket/file: $socket_path" + run_cmd_sudo rm -f "$socket_path" + else + log "Socket/file not present: $socket_path" + fi + done +} + +remove_cron_entries() { + if ! command -v crontab >/dev/null 2>&1; then + log "crontab not available, skipping cron cleanup" + return 0 + fi + + local tmp_file + tmp_file="$(mktemp)" + + if [ "$(id -u)" -eq 0 ]; then + (crontab -l 2>/dev/null || true) > "$tmp_file" + else + (sudo crontab -l 2>/dev/null || true) > "$tmp_file" + fi + + local pattern + for pattern in "${CRON_FILTER_PATTERNS[@]}"; do + if [ "$DRY_RUN" -eq 1 ]; then + echo "[dry-run] would remove cron lines containing: $pattern" + else + grep -vF "$pattern" "$tmp_file" > "${tmp_file}.new" || true + mv "${tmp_file}.new" "$tmp_file" + fi + done + + if [ "$DRY_RUN" -eq 0 ]; then + if [ "$(id -u)" -eq 0 ]; then + crontab "$tmp_file" + else + sudo crontab "$tmp_file" + fi + fi + + rm -f "$tmp_file" + log "Cron cleanup finished" +} + +status_report() { + log "Remaining matching processes:" + ps -eo pid,ppid,cmd --sort=start_time | grep -E "Inventarsystem/.venv/bin/gunicorn|admin_Inventarsystem/.venv/bin/gunicorn" | grep -v grep || echo "none" + + log "Socket status:" + local socket_path + for socket_path in "${SOCKETS[@]}"; do + if [ -e "$socket_path" ]; then + echo "exists: $socket_path" + else + echo "missing: $socket_path" + fi + done +} + +main() { + parse_args "$@" + + stop_services + kill_leftover_processes + remove_stale_sockets + + if [ "$REMOVE_CRON" -eq 1 ]; then + remove_cron_entries + fi + + status_report + log "Cleanup complete" +} + +main "$@" diff --git a/config.json b/config.json new file mode 100755 index 0000000..c48d3c2 --- /dev/null +++ b/config.json @@ -0,0 +1,73 @@ +{ + "dbg": false, + "key": "InventarsystemSecureKey2026XYZ789abcdef012", + "ver": "3.2.1", + "host": "0.0.0.0", + "port": 443, + + "mongodb": { + "host": "localhost", + "port": 27017, + "db": "Inventarsystem" + }, + + "scheduler": { + "enabled": true, + "interval_minutes": 1, + "backup_interval_hours": 24 + }, + + "ssl": { + "enabled": true, + "cert": "Web/certs/cert.pem", + "key": "Web/certs/key.pem" + }, + + "images": { + "thumbnail_size": [150, 150], + "preview_size": [400, 400] + }, + + "upload": { + "max_size_mb": 10, + "image_max_size_mb": 15, + "video_max_size_mb": 100 + }, + + "paths": { + "backups": "backups", + "logs": "logs" + }, + + "modules": { + "library": { + "enabled": true + }, + "student_cards": { + "enabled": true, + "default_borrow_days": 14, + "max_borrow_days": 365 + } + }, + + "allowed_extensions": [ + "png", "jpg", "jpeg", "gif", + "hevc", "heif", + "mp4", "mov", "avi", "mkv", "webm", + "mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", + "pdf", "docx", "xlsx", "pptx", "txt" + ], + + "schoolPeriods": { + "1": { "start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)" }, + "2": { "start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)" }, + "3": { "start": "09:45", "end": "10:30", "label": "3. Stunde (09:45 - 10:30)" }, + "4": { "start": "10:30", "end": "11:15", "label": "4. Stunde (10:30 - 11:15)" }, + "5": { "start": "11:30", "end": "12:15", "label": "5. Stunde (11:30 - 12:15)" }, + "6": { "start": "12:15", "end": "13:00", "label": "6. Stunde (12:15 - 13:00)" }, + "7": { "start": "13:30", "end": "14:15", "label": "7. Stunde (13:30 - 14:15)" }, + "8": { "start": "14:15", "end": "15:00", "label": "8. Stunde (14:15 - 15:00)" }, + "9": { "start": "15:15", "end": "16:00", "label": "9. Stunde (15:15 - 16:00)" }, + "10": { "start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)" } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..33bdd7d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +services: + nginx: + image: nginx:1.27-alpine + container_name: inventarsystem-nginx + restart: unless-stopped + depends_on: + - app + ports: + - "${INVENTAR_HTTP_PORT:-80}:80" + - "${INVENTAR_HTTPS_PORT:-443}:443" + volumes: + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./certs:/etc/nginx/certs:ro + + mongodb: + image: mongo:7.0 + container_name: inventarsystem-mongodb + restart: unless-stopped + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 10s + timeout: 5s + retries: 10 + + app: + build: + context: . + dockerfile: Dockerfile + container_name: inventarsystem-app + restart: unless-stopped + depends_on: + mongodb: + condition: service_healthy + environment: + INVENTAR_MONGODB_HOST: mongodb + INVENTAR_MONGODB_PORT: "27017" + INVENTAR_MONGODB_DB: Inventarsystem + INVENTAR_BACKUP_FOLDER: /data/backups + INVENTAR_LOGS_FOLDER: /data/logs + expose: + - "8000" + volumes: + - ./Web:/app/Web + - app_uploads:/app/Web/uploads + - app_thumbnails:/app/Web/thumbnails + - app_previews:/app/Web/previews + - app_qrcodes:/app/Web/QRCodes + - app_backups:/data/backups + - app_logs:/data/logs + +volumes: + mongodb_data: + app_uploads: + app_thumbnails: + app_previews: + app_qrcodes: + app_backups: + app_logs: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..d866ff4 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,31 @@ +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/inventarsystem.crt; + ssl_certificate_key /etc/nginx/certs/inventarsystem.key; + + client_max_body_size 50M; + + location / { + proxy_pass http://app:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + default_type text/html; + return 200 'Server Error

Server Error

The service is temporarily unavailable.

'; + } +} diff --git a/fix-all.sh b/fix-all.sh new file mode 100755 index 0000000..80a4f90 --- /dev/null +++ b/fix-all.sh @@ -0,0 +1,1104 @@ +#!/bin/bash +# +# fix-all.sh - All-in-One Reparaturskript für das Inventarsystem +# +# Dieses Skript führt alle notwendigen Reparaturschritte für das Inventarsystem durch: +# - Berechtigungen für alle Verzeichnisse und Dateien korrigieren +# - Virtual Environment und Python-Pakete reparieren +# - Git Repository-Berechtigungen setzen +# - Log-Verzeichnisse und Dateien erstellen und Berechtigungen setzen +# - Web-Verzeichnisberechtigungen korrigieren +# - MongoDB-Backup-Verzeichnisse einrichten +# - Bekannte Konflikte zwischen pymongo und bson beheben +# - Uploads-Verzeichnisse sowohl in Entwicklungs- als auch in Produktionsumgebungen verwalten +# +# Verwendung: sudo ./fix-all.sh [--check-only] [--verbose] [--fix-permissions] [--fix-venv] [--fix-pymongo] [--auto] [--setup-cron] + +# Parse command line arguments +CHECK_ONLY=false +VERBOSE=false +FIX_PERMISSIONS=true +FIX_VENV=true +FIX_PYMONGO=true +FIX_PNG_TO_JPG=true +AUTO_MODE=false +SETUP_CRON=false + +for arg in "$@"; do + case $arg in + --check-only) + CHECK_ONLY=true + shift + ;; + --verbose) + VERBOSE=true + shift + ;; + --fix-permissions) + FIX_PERMISSIONS=true + FIX_VENV=false + FIX_PYMONGO=false + shift + ;; + --fix-venv) + FIX_VENV=true + FIX_PERMISSIONS=false + FIX_PYMONGO=false + shift + ;; + --fix-pymongo) + FIX_PYMONGO=true + FIX_PERMISSIONS=false + FIX_VENV=false + FIX_PNG_TO_JPG=false + shift + ;; + --fix-png-jpg) + FIX_PNG_TO_JPG=true + FIX_PERMISSIONS=false + FIX_VENV=false + FIX_PYMONGO=false + shift + ;; + --no-fix-png-jpg) + FIX_PNG_TO_JPG=false + shift + ;; + --auto) + AUTO_MODE=true + shift + ;; + --setup-cron) + SETUP_CRON=true + shift + ;; + --email=*) + echo "Hinweis: Die E-Mail-Funktion wurde entfernt. Option \"$arg\" wird ignoriert." + shift + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "Repariert alle bekannten Probleme im Inventarsystem." + echo "" + echo "Options:" + echo " --check-only Nur Prüfen, keine Änderungen durchführen" + echo " --verbose Ausführlichere Ausgabe" + echo " --fix-permissions Nur Berechtigungen korrigieren" + echo " --fix-venv Nur virtuelle Umgebung reparieren" + echo " --fix-pymongo Nur pymongo/bson-Konflikte beheben" + echo " --fix-png-jpg Nur PNG-zu-JPG Konvertierung durchführen" + echo " --no-fix-png-jpg PNG-zu-JPG Konvertierung überspringen" + echo " --auto Automatischer Modus - erkennt und behebt Probleme ohne Benutzerinteraktion" + echo " --setup-cron Richtet einen Cron-Job für regelmäßige Prüfungen ein" + echo " --help Diese Hilfe anzeigen" + echo "" + echo "Features:" + echo " - Intelligente Diagnose und zielgerichtete Reparatur" + echo " - Automatisches Erstellen und Verknüpfen von Upload-Verzeichnissen" + echo " - Erkennung und Korrektur von Entwicklungs- und Produktionsumgebungen" + echo " - Korrektur von Berechtigungsproblemen und fehlenden Verzeichnissen" + echo " - Behebt Probleme mit fehlenden Buch-Cover-Bildern in der Produktionsumgebung" + exit 0 + ;; + *) + # Unknown option + echo "Unbekannte Option: $arg" + echo "Verwenden Sie --help für Hilfe." + exit 1 + ;; + esac +done + +set -e # Exit on error + +# Get the script directory +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +VENV_DIR="$SCRIPT_DIR/.venv" +LOG_FILE="$SCRIPT_DIR/logs/fix_all.log" + +# Create logs directory immediately +mkdir -p "$SCRIPT_DIR/logs" +chmod -R 777 "$SCRIPT_DIR/logs" 2>/dev/null || true + +# Function to setup a cron job for automatic fixing +setup_cron_job() { + if ! command -v crontab &> /dev/null; then + echo "crontab nicht verfügbar. Cron-Job kann nicht eingerichtet werden." + return 1 + fi + + # Define cron job to run daily at 1:00 AM + CRON_JOB="0 1 * * * cd $SCRIPT_DIR && sudo $SCRIPT_DIR/fix-all.sh --auto --verbose >> $SCRIPT_DIR/logs/auto_fix.log 2>&1" + + # Check if cron job already exists + EXISTING_CRON=$(sudo crontab -l 2>/dev/null | grep -F "fix-all.sh --auto") + + if [ -z "$EXISTING_CRON" ]; then + echo "Richte täglichen Cron-Job für automatische Systemprüfung ein..." + + # Add to root's crontab (since we need sudo privileges) + (sudo crontab -l 2>/dev/null; echo "$CRON_JOB") | sudo crontab - + + if [ $? -eq 0 ]; then + echo "Cron-Job erfolgreich eingerichtet. Das System wird täglich um 1:00 Uhr morgens automatisch überprüft." + else + echo "Fehler beim Einrichten des Cron-Jobs." + return 1 + fi + else + echo "Cron-Job bereits vorhanden, keine Änderungen vorgenommen." + fi + + return 0 +} + +# Email-Versand entfernt: Berichte werden stattdessen in die Konsole und nach logs/auto_fix_report.log geschrieben + +# Function to handle automatic mode +handle_auto_mode() { + local has_problems=0 + local report="" + + # Check permissions + report+="Prüfung: Verzeichnisberechtigungen\n" + check_directory_permissions "$SCRIPT_DIR/logs" "777" "recursive" || has_problems=1 + check_directory_permissions "$SCRIPT_DIR/Web/uploads" "777" "recursive" 2>/dev/null || has_problems=1 + check_directory_permissions "$SCRIPT_DIR/Web/thumbnails" "777" "recursive" 2>/dev/null || has_problems=1 + check_directory_permissions "$SCRIPT_DIR/Web/previews" "777" "recursive" 2>/dev/null || has_problems=1 + check_directory_permissions "$SCRIPT_DIR/Web/QRCodes" "777" "recursive" 2>/dev/null || has_problems=1 + check_directory_permissions "$SCRIPT_DIR/mongodb_backup" "777" "recursive" 2>/dev/null || has_problems=1 + + if [ $has_problems -eq 1 ]; then + report+="Status: Probleme mit Berechtigungen gefunden, werden repariert\n" + else + report+="Status: Keine Berechtigungsprobleme gefunden\n" + fi + + # Check virtual environment + report+="\nPrüfung: Virtuelle Python-Umgebung\n" + set +e + check_venv_health + local venv_status=$? + set -e + + if [ $venv_status -ne 0 ]; then + has_problems=1 + report+="Status: Probleme mit virtueller Umgebung gefunden, werden repariert\n" + else + report+="Status: Virtuelle Umgebung ist gesund\n" + fi + + # Check for pymongo/bson conflict + report+="\nPrüfung: PyMongo/BSON-Konflikte\n" + set +e + check_pymongo_bson_conflict + local pymongo_status=$? + set -e + + if [ $pymongo_status -ne 0 ] && [ $pymongo_status -ne 2 ]; then + has_problems=1 + report+="Status: PyMongo/BSON-Konflikte gefunden, werden repariert\n" + elif [ $pymongo_status -eq 2 ]; then + report+="Status: PyMongo/BSON konnte nicht überprüft werden\n" + else + report+="Status: Keine PyMongo/BSON-Konflikte gefunden\n" + fi + + # Check system services + report+="\nPrüfung: Systemdienste\n" + set +e + check_services + local services_status=$? + set -e + + if [ $services_status -ne 0 ] && [ $services_status -ne 2 ]; then + has_problems=1 + report+="Status: Einige Dienste sind nicht aktiv, werden gestartet\n" + elif [ $services_status -eq 2 ]; then + report+="Status: Systemdienste konnten nicht überprüft werden\n" + else + report+="Status: Alle Dienste sind aktiv\n" + fi + + # If no problems or in check-only mode, just return + if [ $has_problems -eq 0 ]; then + report+="\nErgebnis: Alle Systeme funktionieren korrekt. Keine Reparaturen notwendig.\n" + # Print report to console and write to file + echo -e "$report" + echo -e "$report" >> "$SCRIPT_DIR/logs/auto_fix_report.log" + return 0 + fi + + if [ "$CHECK_ONLY" = true ]; then + report+="\nErgebnis: Probleme gefunden, aber nur Prüfung durchgeführt (--check-only).\n" + # Print report to console and write to file + echo -e "$report" + echo -e "$report" >> "$SCRIPT_DIR/logs/auto_fix_report.log" + return 1 + fi + + # Auto fix the issues + report+="\nStarte automatische Reparatur...\n" + + # Fix permissions if needed + if [ $has_problems -eq 1 ]; then + # All the fixes will be performed by the main script + report+="\nReparaturen wurden durchgeführt. Details im Log: $LOG_FILE\n" + fi + + # Print report to console and write to file + echo -e "$report" + echo -e "$report" >> "$SCRIPT_DIR/logs/auto_fix_report.log" + + return 0 +} + +# Function to log messages +log_message() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo -e "[\e[1;36m$timestamp\e[0m] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo -e "[\e[1;36m$timestamp\e[0m] $1" +} + +# Function to log success messages +log_success() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo -e "[\e[1;32m$timestamp\e[0m] ✓ $1" | tee -a "$LOG_FILE" 2>/dev/null || echo -e "[\e[1;32m$timestamp\e[0m] ✓ $1" +} + +# Function to log error messages +log_error() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo -e "[\e[1;31m$timestamp\e[0m] ✗ $1" | tee -a "$LOG_FILE" 2>/dev/null || echo -e "[\e[1;31m$timestamp\e[0m] ✗ $1" +} + +# Function to log warning messages +log_warning() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo -e "[\e[1;33m$timestamp\e[0m] ! $1" | tee -a "$LOG_FILE" 2>/dev/null || echo -e "[\e[1;33m$timestamp\e[0m] ! $1" +} + +# Function to log verbose messages +log_verbose() { + if [ "$VERBOSE" = true ]; then + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo -e "[\e[1;90m$timestamp\e[0m] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo -e "[\e[1;90m$timestamp\e[0m] $1" + fi +} + +# Function to log section headers +log_section() { + echo "" + echo -e "\e[1;33m====== $1 ======\e[0m" | tee -a "$LOG_FILE" 2>/dev/null || echo -e "\e[1;33m====== $1 ======\e[0m" + echo "" +} + +# Function to check if a directory has correct permissions +check_directory_permissions() { + local dir="$1" + local expected_perm="$2" + local is_recursive="$3" + local result=0 + + if [ ! -d "$dir" ]; then + log_verbose "Verzeichnis nicht gefunden: $dir" + return 2 + fi + + if [ "$is_recursive" = "recursive" ]; then + find "$dir" -type d -not -perm "$expected_perm" | grep -q . && result=1 + else + [ "$(stat -c %a "$dir")" != "$expected_perm" ] && result=1 + fi + + if [ $result -eq 1 ]; then + return 1 + fi + return 0 +} + +# Function to check if a file has correct permissions +check_file_permissions() { + local file="$1" + local expected_perm="$2" + + if [ ! -f "$file" ]; then + log_verbose "Datei nicht gefunden: $file" + return 2 + fi + + if [ "$(stat -c %a "$file")" != "$expected_perm" ]; then + return 1 + fi + return 0 +} + +# Function to check if pymongo/bson conflict exists +check_pymongo_bson_conflict() { + if [ ! -d "$VENV_DIR" ]; then + log_verbose "Virtuelle Umgebung nicht gefunden" + return 2 + fi + + # Check for standalone bson package + if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ] && [ -d "$VENV_DIR/lib/python3.12/site-packages/pymongo" ]; then + return 1 + fi + return 0 +} + +# Function to check if virtual environment is healthy +check_venv_health() { + if [ ! -d "$VENV_DIR" ]; then + log_verbose "Virtuelle Umgebung nicht gefunden" + return 2 + fi + + # Check for critical components + if [ ! -f "$VENV_DIR/bin/python" ] || [ ! -f "$VENV_DIR/bin/pip" ]; then + return 1 + fi + + # Try importing the runtime packages required at app startup + if ! "$VENV_DIR/bin/python" -c "import pymongo, flask, gunicorn, requests, PIL, apscheduler" 2>/dev/null; then + return 1 + fi + + return 0 +} + +# Function to create and fix upload directories in all environments +fix_uploads_directories() { + log_message "Erstelle und korrigiere Upload-Verzeichnisse in allen Umgebungen..." + + # Development environment directories + local DEV_DIRS=( + "$SCRIPT_DIR/Web/uploads" + "$SCRIPT_DIR/Web/thumbnails" + "$SCRIPT_DIR/Web/previews" + "$SCRIPT_DIR/Web/QRCodes" + ) + + # Production environment directories + local PROD_DIRS=( + "/var/Inventarsystem/Web/uploads" + "/var/Inventarsystem/Web/thumbnails" + "/var/Inventarsystem/Web/previews" + "/var/Inventarsystem/Web/QRCodes" + ) + + # Create and set permissions for development directories + for dir in "${DEV_DIRS[@]}"; do + if [ ! -d "$dir" ]; then + log_message "Erstelle Verzeichnis: $dir" + mkdir -p "$dir" 2>/dev/null || log_error "Fehler beim Erstellen von $dir" + fi + + log_message "Setze Berechtigungen für $dir" + chmod -R 777 "$dir" 2>/dev/null || log_error "Fehler beim Setzen von Berechtigungen für $dir" + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$dir" 2>/dev/null || log_error "Fehler beim Setzen von Eigentümerrechten für $dir" + done + + # Check if we are in production environment + if [ -d "/var/Inventarsystem" ] || [ -L "/var/Inventarsystem" ]; then + # Create and set permissions for production directories + for dir in "${PROD_DIRS[@]}"; do + if [ ! -d "$dir" ]; then + log_message "Erstelle Produktions-Verzeichnis: $dir" + mkdir -p "$dir" 2>/dev/null || log_error "Fehler beim Erstellen von $dir" + fi + + log_message "Setze Berechtigungen für $dir" + chmod -R 777 "$dir" 2>/dev/null || log_error "Fehler beim Setzen von Berechtigungen für $dir" + + # In production, use www-data as owner + chown -R www-data:www-data "$dir" 2>/dev/null || log_error "Fehler beim Setzen von Produktions-Eigentümerrechten für $dir" + done + + # Create symlinks if needed - for example if the app is looking in one path but files are in another + # This ensures that whether the app looks in dev or prod paths, it will find the files + if [ ! -L "/var/Inventarsystem/Web/uploads" ] && [ -d "$SCRIPT_DIR/Web/uploads" ]; then + # If production directory is empty but development has files, create symlink + if [ -z "$(ls -A "/var/Inventarsystem/Web/uploads" 2>/dev/null)" ] && [ -n "$(ls -A "$SCRIPT_DIR/Web/uploads" 2>/dev/null)" ]; then + log_message "Produktions-Upload-Verzeichnis ist leer, erstelle Symlink zu Entwicklungsverzeichnis" + rm -rf "/var/Inventarsystem/Web/uploads" 2>/dev/null + ln -sf "$SCRIPT_DIR/Web/uploads" "/var/Inventarsystem/Web/uploads" 2>/dev/null || log_error "Fehler beim Erstellen des Symlinks für uploads" + fi + fi + + # Ensure the application can find images in either location + log_message "Überprüfe Dateiübereinstimmung zwischen Entwicklungs- und Produktionsumgebung" + for env_dir in uploads thumbnails previews QRCodes; do + dev_dir="$SCRIPT_DIR/Web/$env_dir" + prod_dir="/var/Inventarsystem/Web/$env_dir" + + # If both directories exist and are not symlinks to each other + if [ -d "$dev_dir" ] && [ -d "$prod_dir" ] && [ ! -L "$prod_dir" ]; then + # Check for missing files in production that exist in development + for file in $(find "$dev_dir" -type f -name "*" 2>/dev/null); do + filename=$(basename "$file") + if [ ! -f "$prod_dir/$filename" ]; then + log_message "Kopiere fehlende Datei in die Produktionsumgebung: $filename" + cp -f "$file" "$prod_dir/" 2>/dev/null || log_error "Fehler beim Kopieren von $filename nach $prod_dir" + chmod 666 "$prod_dir/$filename" 2>/dev/null + fi + done + + # Check for missing files in development that exist in production + for file in $(find "$prod_dir" -type f -name "*" 2>/dev/null); do + filename=$(basename "$file") + if [ ! -f "$dev_dir/$filename" ]; then + log_message "Kopiere fehlende Datei in die Entwicklungsumgebung: $filename" + cp -f "$file" "$dev_dir/" 2>/dev/null || log_error "Fehler beim Kopieren von $filename nach $dev_dir" + chmod 666 "$dev_dir/$filename" 2>/dev/null + fi + done + fi + done + fi + + log_success "Upload-Verzeichnisse wurden erstellt und Berechtigungen gesetzt" +} + +# Function to ensure PNG images are converted to JPG for universal compatibility +fix_png_to_jpg_conversion() { + if [ "$FIX_PNG_TO_JPG" != true ]; then + log_verbose "PNG zu JPG Konvertierung übersprungen (--no-fix-png-jpg Flag gesetzt)" + return 0 + fi + + log_section "Überprüfe PNG zu JPG Konvertierung" + log_message "Das System verwendet einheitlich JPG-Dateien für maximale Kompatibilität." + + # Check if the converter script exists + if [ ! -f "$SCRIPT_DIR/png_jpg_converter.py" ]; then + log_error "PNG zu JPG Konverter-Skript nicht gefunden: png_jpg_converter.py" + return 1 + fi + + # Check if PIL/Pillow is installed in the virtual environment + if ! "$VENV_DIR/bin/pip" list | grep -i "pillow" >/dev/null 2>&1; then + log_message "Installiere Pillow für Bildkonvertierung..." + "$VENV_DIR/bin/pip" install pillow >/dev/null 2>&1 + if [ $? -ne 0 ]; then + log_error "Konnte Pillow nicht installieren" + return 1 + fi + log_success "Pillow erfolgreich installiert" + fi + + # Run the converter in dry-run mode first + log_message "Überprüfe PNG-Dateien im System..." + PNG_COUNT=$("$VENV_DIR/bin/python" "$SCRIPT_DIR/png_jpg_converter.py" | grep "Total PNG files:" | awk '{print $4}') + + if [ -z "$PNG_COUNT" ]; then + PNG_COUNT=0 + fi + + if [ "$PNG_COUNT" -eq 0 ]; then + log_success "Keine PNG-Dateien gefunden, System verwendet bereits einheitlich JPG-Dateien" + return 0 + fi + + log_message "Gefunden: $PNG_COUNT PNG-Dateien, die zu JPG konvertiert werden sollten" + + if [ "$CHECK_ONLY" = true ]; then + log_warning "Im Check-Only-Modus wird keine Konvertierung durchgeführt" + return 0 + fi + + # Confirm with user in interactive mode + if [ "$AUTO_MODE" != true ]; then + read -p "Möchten Sie alle PNG-Dateien zu JPG konvertieren? (j/n): " confirm + if [[ ! "$confirm" =~ ^[jJ] ]]; then + log_message "PNG zu JPG Konvertierung übersprungen" + return 0 + fi + fi + + # Run the converter with execute flag + log_message "Konvertiere PNG-Dateien zu JPG..." + "$VENV_DIR/bin/python" "$SCRIPT_DIR/png_jpg_converter.py" --execute --quality 95 + + if [ $? -eq 0 ]; then + log_success "PNG zu JPG Konvertierung erfolgreich abgeschlossen" + else + log_error "Fehler bei der PNG zu JPG Konvertierung" + return 1 + fi + + return 0 +} + +# Function to check if system services are running +check_services() { + if ! command -v systemctl >/dev/null 2>&1; then + log_verbose "systemctl nicht verfügbar" + return 2 + fi + + local services_status=0 + + if ! systemctl is-active --quiet mongodb 2>/dev/null; then + services_status=1 + fi + + if ! systemctl is-active --quiet inventarsystem-gunicorn.service 2>/dev/null; then + services_status=1 + fi + + if ! systemctl is-active --quiet inventarsystem-nginx.service 2>/dev/null; then + services_status=1 + fi + + return $services_status +} + +# Function to check if we are in production environment +check_production_environment() { + local prod_path="/var/Inventarsystem" + local in_prod=false + + if [ -d "$prod_path" ] || [ -L "$prod_path" ]; then + in_prod=true + log_message "Produktionsumgebung erkannt: $prod_path" + + # Verify directory structure + if [ ! -d "$prod_path/Web" ]; then + log_warning "Produktionsumgebung unvollständig: Web-Verzeichnis fehlt" + mkdir -p "$prod_path/Web" 2>/dev/null || log_error "Konnte Web-Verzeichnis nicht erstellen" + fi + else + log_verbose "Keine Produktionsumgebung unter $prod_path gefunden" + fi + + # Check if we're installed to /var/www + if [ -d "/var/www/Inventarsystem" ] || [ -L "/var/www/Inventarsystem" ]; then + in_prod=true + log_message "Alternative Produktionsumgebung erkannt: /var/www/Inventarsystem" + fi + + if [ "$in_prod" = true ]; then + return 0 + else + return 1 + fi +} + +# Check if running as root +if [ "$(id -u)" -ne 0 ]; then + log_error "Dieses Skript muss als root oder mit sudo ausgeführt werden" + log_error "Bitte starten Sie das Skript mit: sudo $0" + exit 1 +fi + +# Determine the actual user who invoked sudo +if [ -n "$SUDO_USER" ]; then + ACTUAL_USER="$SUDO_USER" + ACTUAL_GROUP="$(id -gn "$SUDO_USER")" +else + # If not using sudo, use the current user + ACTUAL_USER="$(whoami)" + ACTUAL_GROUP="$(id -gn)" +fi + +log_section "ALL-IN-ONE REPARATUR GESTARTET" +log_message "Benutzerinformation: $ACTUAL_USER:$ACTUAL_GROUP" +log_message "Systempfad: $SCRIPT_DIR" +if [ "$CHECK_ONLY" = true ]; then + log_message "Modus: Nur Prüfung (keine Änderungen werden durchgeführt)" +fi + +# Diagnostic phase - check what needs to be fixed +log_section "SYSTEMDIAGNOSE" +log_message "Überprüfe den aktuellen Systemzustand..." + +PERM_STATUS=0 +VENV_STATUS=0 +PYMONGO_STATUS=0 +SERVICES_STATUS=0 + +# Check permissions +log_message "Überprüfe Verzeichnisberechtigungen..." +check_directory_permissions "$SCRIPT_DIR/logs" "777" "recursive" || PERM_STATUS=1 +check_directory_permissions "$SCRIPT_DIR/Web/uploads" "777" "recursive" 2>/dev/null || PERM_STATUS=1 +check_directory_permissions "$SCRIPT_DIR/Web/thumbnails" "777" "recursive" 2>/dev/null || PERM_STATUS=1 +check_directory_permissions "$SCRIPT_DIR/Web/previews" "777" "recursive" 2>/dev/null || PERM_STATUS=1 +check_directory_permissions "$SCRIPT_DIR/Web/QRCodes" "777" "recursive" 2>/dev/null || PERM_STATUS=1 +check_directory_permissions "$SCRIPT_DIR/mongodb_backup" "777" "recursive" 2>/dev/null || PERM_STATUS=1 + +# Check virtual environment +log_message "Überprüfe virtuelle Python-Umgebung..." +set +e +check_venv_health +VENV_STATUS=$? +set -e + +# Check for pymongo/bson conflict +log_message "Überprüfe auf pymongo/bson-Konflikte..." +set +e +check_pymongo_bson_conflict +PYMONGO_STATUS=$? +set -e + +# Check system services +log_message "Überprüfe Systemdienste..." +set +e +check_services +SERVICES_STATUS=$? +set -e + +# Display diagnosis results +log_section "DIAGNOSEERGEBNISSE" +if [ $PERM_STATUS -eq 0 ]; then + log_success "Verzeichnisberechtigungen: Korrekt" +else + log_warning "Verzeichnisberechtigungen: Probleme gefunden" +fi + +if [ $VENV_STATUS -eq 0 ]; then + log_success "Virtuelle Python-Umgebung: Gesund" +elif [ $VENV_STATUS -eq 2 ]; then + log_warning "Virtuelle Python-Umgebung: Nicht gefunden" +else + log_warning "Virtuelle Python-Umgebung: Probleme gefunden" +fi + +if [ $PYMONGO_STATUS -eq 0 ]; then + log_success "PyMongo/BSON: Keine Konflikte gefunden" +elif [ $PYMONGO_STATUS -eq 2 ]; then + log_warning "PyMongo/BSON: Konnte nicht überprüft werden" +else + log_warning "PyMongo/BSON: Konflikt gefunden" +fi + +if [ $SERVICES_STATUS -eq 0 ]; then + log_success "Systemdienste: Alle aktiv" +elif [ $SERVICES_STATUS -eq 2 ]; then + log_warning "Systemdienste: Konnte nicht überprüft werden" +else + log_warning "Systemdienste: Einige Dienste sind nicht aktiv" +fi + +# Setup cron job if requested +if [ "$SETUP_CRON" = true ]; then + log_section "CRON-JOB EINRICHTUNG" + + if setup_cron_job; then + log_success "Cron-Job für automatische Prüfung wurde eingerichtet" + log_message "Das System wird täglich um 1:00 Uhr morgens automatisch überprüft und repariert" + else + log_error "Fehler beim Einrichten des Cron-Jobs" + fi + + # If only setting up cron job, exit here + if [ "$AUTO_MODE" = false ] && [ "$CHECK_ONLY" = true ]; then + exit 0 + fi +fi + +# Handle auto mode if enabled +if [ "$AUTO_MODE" = true ]; then + log_section "AUTOMATISCHER MODUS" + log_message "Automatischer Reparaturmodus ist aktiviert" + log_message "Überprüfe und behebe Probleme ohne Benutzerinteraktion..." + + # Let the auto mode handler determine if fixes are needed, but don't abort on non-zero due to set -e + set +e + handle_auto_mode + HM_STATUS=$? + set -e + + # If auto mode with check-only, exit here + if [ "$CHECK_ONLY" = true ]; then + log_section "AUTOMATISCHE PRÜFUNG ABGESCHLOSSEN" + log_message "Keine Änderungen wurden durchgeführt (--check-only ist aktiviert)" + log_message "Prüfbericht wurde in logs/auto_fix_report.log gespeichert" + exit $HM_STATUS + fi +fi + +# If check-only mode, exit here +if [ "$CHECK_ONLY" = true ]; then + log_section "PRÜFUNG ABGESCHLOSSEN" + log_message "Keine Änderungen wurden durchgeführt." + log_message "Um Probleme zu beheben, führen Sie das Skript ohne --check-only aus." + exit 0 +fi + +# Fix phase - only fix what's needed based on diagnosis +log_section "REPARATURPHASE" + +# Step 1: Create and fix permissions for critical directories +if [ $PERM_STATUS -ne 0 ] && [ "$FIX_PERMISSIONS" = true ]; then + log_section "VERZEICHNISBERECHTIGUNGEN KORRIGIEREN" + + # Logs directory + log_message "Berechtigungen für logs-Verzeichnis korrigieren..." + mkdir -p "$SCRIPT_DIR/logs" + chmod -R 777 "$SCRIPT_DIR/logs" 2>/dev/null || log_error "Fehler beim Setzen von Berechtigungen für logs-Verzeichnis" + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR/logs" 2>/dev/null || log_error "Fehler beim Setzen von Eigentümerrechten für logs-Verzeichnis" + + # Create log files if they don't exist + log_message "Erstelle Log-Dateien mit korrekten Berechtigungen..." + touch "$SCRIPT_DIR/logs/Backup_db.log" "$SCRIPT_DIR/logs/daily_update.log" "$SCRIPT_DIR/logs/error.log" "$SCRIPT_DIR/logs/access.log" "$SCRIPT_DIR/logs/permission_fixes.log" "$SCRIPT_DIR/logs/fix_all.log" "$SCRIPT_DIR/logs/scheduler.log" "$SCRIPT_DIR/logs/restore.log" 2>/dev/null + chmod 666 "$SCRIPT_DIR/logs"/*.log 2>/dev/null + chown "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR/logs"/*.log 2>/dev/null + + # Fix all upload directories in both development and production environments + fix_uploads_directories + + # Convert any PNG files to JPG for universal compatibility + fix_png_to_jpg_conversion + + # Fix Web directory permissions and structure + if [ -d "$SCRIPT_DIR/Web" ]; then + log_message "Berechtigungen für Web-Verzeichnis korrigieren..." + + # Set base permissions for Web directory + chmod -R 755 "$SCRIPT_DIR/Web" 2>/dev/null || log_error "Fehler beim Setzen von Berechtigungen für Web-Verzeichnis" + + # Set ownership + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR/Web" 2>/dev/null || log_error "Fehler beim Setzen von Eigentümerrechten für Web-Verzeichnis" + + # Create and fix all uploads directories both in development and production + fix_uploads_directories + + log_success "Web-Verzeichnisberechtigungen korrigiert" + else + log_error "Web-Verzeichnis nicht gefunden: $SCRIPT_DIR/Web" + mkdir -p "$SCRIPT_DIR/Web" 2>/dev/null && { + log_message "Web-Verzeichnis wurde erstellt" + chmod 755 "$SCRIPT_DIR/Web" 2>/dev/null + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR/Web" 2>/dev/null + fix_uploads_directories + } + fi + + # Create and set permissions for SSL certificates directory + if [ -d "$SCRIPT_DIR/certs" ]; then + log_message "Berechtigungen für SSL-Zertifikate korrigieren..." + chmod 755 "$SCRIPT_DIR/certs" 2>/dev/null + find "$SCRIPT_DIR/certs" -name "*.key" -exec chmod 600 {} \; 2>/dev/null + find "$SCRIPT_DIR/certs" -name "*.crt" -exec chmod 644 {} \; 2>/dev/null + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR/certs" 2>/dev/null + log_success "SSL-Zertifikatberechtigungen korrigiert" + fi + + # Create and set permissions for backups directory + log_message "Berechtigungen für Backup-Verzeichnisse korrigieren..." + BACKUP_BASE_DIR="/var/backups" + if [ ! -d "$BACKUP_BASE_DIR" ]; then + mkdir -p "$BACKUP_BASE_DIR" 2>/dev/null + fi + chmod 755 "$BACKUP_BASE_DIR" 2>/dev/null || log_error "Fehler beim Setzen von Berechtigungen für $BACKUP_BASE_DIR" + + # Local backups directory + mkdir -p "$SCRIPT_DIR/backups" 2>/dev/null + chmod 777 "$SCRIPT_DIR/backups" 2>/dev/null + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR/backups" 2>/dev/null + + # MongoDB backup directory + mkdir -p "$SCRIPT_DIR/mongodb_backup" 2>/dev/null + chmod 777 "$SCRIPT_DIR/mongodb_backup" 2>/dev/null + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR/mongodb_backup" 2>/dev/null + log_success "Backup-Verzeichnisberechtigungen korrigiert" + + # Step 2: Fix Git repository permissions + log_section "GIT REPOSITORY BERECHTIGUNGEN" + + log_message "Setze Git Repository als sicheres Verzeichnis..." + # For root + git config --global --add safe.directory "$SCRIPT_DIR" 2>/dev/null || log_error "Fehler beim Setzen des Git safe.directory für root" + + # For the actual user (if different from root) + if [ "$ACTUAL_USER" != "root" ]; then + su - "$ACTUAL_USER" -c "git config --global --add safe.directory '$SCRIPT_DIR'" 2>/dev/null || log_error "Fehler beim Setzen des Git safe.directory für $ACTUAL_USER" + fi + log_success "Git Repository-Berechtigungen korrigiert" + + # Step 3: Fix executable permissions for scripts + log_section "SKRIPT-BERECHTIGUNGEN" + + log_message "Mache alle Skripte ausführbar..." + find "$SCRIPT_DIR" -name "*.sh" -type f -exec chmod +x {} \; 2>/dev/null + find "$SCRIPT_DIR" -name "*.py" -type f -exec chmod +x {} \; 2>/dev/null + log_success "Skript-Berechtigungen korrigiert" +else + if [ "$FIX_PERMISSIONS" = true ]; then + log_success "Überspringe Berechtigungskorrekturen - alle Berechtigungen sind bereits korrekt" + else + log_verbose "Berechtigungskorrekturen wurden übersprungen (gemäß Befehlszeilenoption)" + fi +fi + +# Step 4: Fix virtual environment +if [ $VENV_STATUS -ne 0 ] && [ "$FIX_VENV" = true ]; then + log_section "PYTHON VIRTUELLE UMGEBUNG" + + log_message "Repariere virtuelle Python-Umgebung..." + if [ ! -d "$VENV_DIR" ]; then + log_message "Virtuelle Umgebung nicht gefunden. Erstelle neue Umgebung..." + python3 -m venv "$VENV_DIR" || { + log_error "Fehler beim Erstellen der virtuellen Umgebung" + log_message "Versuche alternative Methode..." + python3 -m venv "$VENV_DIR" --system-site-packages || { + log_error "Virtuelle Umgebung konnte nicht erstellt werden. Abbruch." + exit 1 + } + } + # Set ownership immediately after creation + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$VENV_DIR" 2>/dev/null + log_success "Neue virtuelle Umgebung erstellt" + else + log_message "Bestehende virtuelle Umgebung gefunden. Setze Berechtigungen..." + fi + + # Fix directory and file permissions + log_message "Setze Verzeichnis- und Dateirechte..." + find "$VENV_DIR" -type d -exec chmod 755 {} \; 2>/dev/null || log_error "Fehler beim Setzen der Verzeichnisberechtigungen" + find "$VENV_DIR" -type f -exec chmod 644 {} \; 2>/dev/null || log_error "Fehler beim Setzen der Dateiberechtigungen" + + # Make bin files executable + if [ -d "$VENV_DIR/bin" ]; then + log_message "Mache Bin-Dateien ausführbar..." + chmod 755 "$VENV_DIR/bin" 2>/dev/null + find "$VENV_DIR/bin" -type f -exec chmod 755 {} \; 2>/dev/null || log_error "Fehler beim Setzen der Bin-Dateirechte" + fi + + # Set ownership for virtual environment + log_message "Setze Eigentümerrechte für virtuelle Umgebung..." + chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$VENV_DIR" 2>/dev/null || log_error "Fehler beim Setzen der Eigentümerrechte für virtuelle Umgebung" + + log_success "Berechtigungen für virtuelle Umgebung korrigiert" +else + if [ "$FIX_VENV" = true ]; then + log_success "Überspringe Reparatur der virtuellen Umgebung - bereits in gutem Zustand" + else + log_verbose "Reparatur der virtuellen Umgebung wurde übersprungen (gemäß Befehlszeilenoption)" + fi +fi + +# Step 5: Fix pymongo/bson conflict +if [ $PYMONGO_STATUS -ne 0 ] && [ "$FIX_PYMONGO" = true ]; then + log_section "PYMONGO/BSON KONFLIKTE" + + # Detect bson/pymongo conflict + if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then + log_message "Potentieller bson/pymongo Konflikt erkannt. Behebe das Problem..." + rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson" || { + log_error "Fehler beim Entfernen des Bson-Verzeichnisses. Versuche mit sudo..." + sudo rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson" || { + log_error "Fehler beim Entfernen des Bson-Verzeichnisses." + } + } + log_message "Konfliktierendes Bson-Paket entfernt" + fi + + # Install/upgrade pip in the virtual environment + log_message "Installiere/aktualisiere pip in der virtuellen Umgebung..." + + # Try to activate the virtual environment and install packages + if [ -f "$VENV_DIR/bin/pip" ]; then + log_message "Benutze pip aus der virtuellen Umgebung..." + + # Remove conflicting packages + "$VENV_DIR/bin/pip" uninstall -y bson pymongo 2>/dev/null || true + + # Install pymongo + "$VENV_DIR/bin/pip" install --upgrade pip 2>/dev/null || log_error "Fehler beim Aktualisieren von pip" + "$VENV_DIR/bin/pip" install pymongo==4.6.1 2>/dev/null || { + log_error "Fehler beim Installieren von pymongo mit venv pip" + "$VENV_DIR/bin/python" -m pip install pymongo==4.6.1 2>/dev/null || { + log_error "Alle Versuche zur Installation von pymongo in der virtuellen Umgebung sind fehlgeschlagen" + } + } + + # Verify core runtime imports + if "$VENV_DIR/bin/python" -c "import pymongo, flask, gunicorn, requests, PIL, apscheduler" 2>/dev/null; then + log_success "Runtime-Abhängigkeiten wurden korrekt installiert" + else + log_error "Runtime-Abhängigkeiten konnten nicht verifiziert werden" + fi + + # Install requirements + if [ -f "$SCRIPT_DIR/requirements.txt" ]; then + log_message "Installiere Abhängigkeiten aus requirements.txt..." + # Filter out pymongo + grep -v "^pymongo" "$SCRIPT_DIR/requirements.txt" > "$SCRIPT_DIR/requirements_filtered.txt" + "$VENV_DIR/bin/pip" install -r "$SCRIPT_DIR/requirements_filtered.txt" 2>/dev/null || log_error "Fehler beim Installieren einiger Abhängigkeiten" + rm -f "$SCRIPT_DIR/requirements_filtered.txt" + fi + else + log_error "Pip nicht in der virtuellen Umgebung gefunden" + log_message "Versuche die Umgebung zu aktivieren und pip zu verwenden..." + + source "$VENV_DIR/bin/activate" 2>/dev/null && { + pip install --upgrade pip 2>/dev/null || log_error "Fehler beim Aktualisieren von pip" + pip uninstall -y bson pymongo 2>/dev/null || true + pip install pymongo==4.6.1 2>/dev/null || log_error "Fehler beim Installieren von pymongo" + + # Verify core runtime imports + if python -c "import pymongo, flask, gunicorn, requests, PIL, apscheduler" 2>/dev/null; then + log_success "Runtime-Abhängigkeiten wurden korrekt installiert" + else + log_error "Runtime-Abhängigkeiten konnten nicht verifiziert werden" + fi + + # Install requirements + if [ -f "$SCRIPT_DIR/requirements.txt" ]; then + log_message "Installiere Abhängigkeiten aus requirements.txt..." + grep -v "^pymongo" "$SCRIPT_DIR/requirements.txt" > "$SCRIPT_DIR/requirements_filtered.txt" + pip install -r "$SCRIPT_DIR/requirements_filtered.txt" 2>/dev/null || log_error "Fehler beim Installieren einiger Abhängigkeiten" + rm -f "$SCRIPT_DIR/requirements_filtered.txt" + fi + + deactivate + } || log_error "Konnte virtuelle Umgebung nicht aktivieren" + fi +else + if [ "$FIX_PYMONGO" = true ]; then + log_success "Überspringe PyMongo/BSON-Reparatur - keine Konflikte gefunden" + else + log_verbose "PyMongo/BSON-Reparatur wurde übersprungen (gemäß Befehlszeilenoption)" + fi +fi + +# Step 6: Check system service status +log_section "SYSTEMDIENSTE ÜBERPRÜFEN" + +log_message "Überprüfe Systemdienste..." +if command -v systemctl >/dev/null 2>&1; then + if systemctl is-active --quiet mongodb; then + log_success "MongoDB-Dienst läuft" + else + log_warning "MongoDB-Dienst läuft nicht" + log_message "Versuche MongoDB zu starten..." + systemctl start mongodb 2>/dev/null || log_error "Konnte MongoDB-Dienst nicht starten" + fi + + # Check for Inventarsystem services + if systemctl is-active --quiet inventarsystem-gunicorn.service 2>/dev/null; then + log_success "Inventarsystem Gunicorn-Dienst läuft" + else + log_warning "Inventarsystem Gunicorn-Dienst läuft nicht" + fi + + if systemctl is-active --quiet inventarsystem-nginx.service 2>/dev/null; then + log_success "Inventarsystem Nginx-Dienst läuft" + else + log_warning "Inventarsystem Nginx-Dienst läuft nicht" + fi +else + log_message "Systemctl nicht verfügbar. Dienststatus kann nicht überprüft werden." +fi + +# Step 7: Set correct ownership for entire project +log_section "PROJEKT-EIGENTÜMERRECHTE" + +log_message "Setze Eigentümerrechte für das gesamte Projekt..." +chown -R "$ACTUAL_USER:$ACTUAL_GROUP" "$SCRIPT_DIR" 2>/dev/null || log_error "Fehler beim Setzen der Eigentümerrechte für das gesamte Projekt" + +log_success "Projekt-Eigentümerrechte gesetzt" + +# Run a second diagnostic to confirm fixes +log_section "REPARATURVERIFIKATION" +log_message "Überprüfe den Systemzustand nach Reparaturen..." + +PERM_STATUS_AFTER=0 +VENV_STATUS_AFTER=0 +PYMONGO_STATUS_AFTER=0 +SERVICES_STATUS_AFTER=0 + +# Check permissions +check_directory_permissions "$SCRIPT_DIR/logs" "777" "recursive" || PERM_STATUS_AFTER=1 +check_directory_permissions "$SCRIPT_DIR/Web/uploads" "777" "recursive" 2>/dev/null || PERM_STATUS_AFTER=1 +check_directory_permissions "$SCRIPT_DIR/Web/thumbnails" "777" "recursive" 2>/dev/null || PERM_STATUS_AFTER=1 +check_directory_permissions "$SCRIPT_DIR/Web/previews" "777" "recursive" 2>/dev/null || PERM_STATUS_AFTER=1 +check_directory_permissions "$SCRIPT_DIR/Web/QRCodes" "777" "recursive" 2>/dev/null || PERM_STATUS_AFTER=1 +check_directory_permissions "$SCRIPT_DIR/mongodb_backup" "777" "recursive" 2>/dev/null || PERM_STATUS_AFTER=1 + +# Check virtual environment +set +e +check_venv_health +VENV_STATUS_AFTER=$? +set -e + +# Check for pymongo/bson conflict +set +e +check_pymongo_bson_conflict +PYMONGO_STATUS_AFTER=$? +set -e + +# Check system services +set +e +check_services +SERVICES_STATUS_AFTER=$? +set -e + +# Report fixed and remaining issues +if [ $PERM_STATUS_AFTER -eq 0 ] && [ $PERM_STATUS -ne 0 ]; then + log_success "Verzeichnisberechtigungen wurden erfolgreich korrigiert" +elif [ $PERM_STATUS_AFTER -ne 0 ] && [ $PERM_STATUS -ne 0 ]; then + log_error "Es bestehen weiterhin Probleme mit Verzeichnisberechtigungen" +fi + +if [ $VENV_STATUS_AFTER -eq 0 ] && [ $VENV_STATUS -ne 0 ]; then + log_success "Virtuelle Python-Umgebung wurde erfolgreich repariert" +elif [ $VENV_STATUS_AFTER -ne 0 ] && [ $VENV_STATUS -ne 0 ]; then + log_error "Es bestehen weiterhin Probleme mit der virtuellen Python-Umgebung" +fi + +if [ $PYMONGO_STATUS_AFTER -eq 0 ] && [ $PYMONGO_STATUS -ne 0 ]; then + log_success "PyMongo/BSON-Konflikte wurden erfolgreich behoben" +elif [ $PYMONGO_STATUS_AFTER -ne 0 ] && [ $PYMONGO_STATUS -ne 0 ]; then + log_error "Es bestehen weiterhin PyMongo/BSON-Konflikte" +fi + +if [ $SERVICES_STATUS_AFTER -eq 0 ] && [ $SERVICES_STATUS -ne 0 ]; then + log_success "Systemdienste wurden erfolgreich gestartet" +elif [ $SERVICES_STATUS_AFTER -ne 0 ] && [ $SERVICES_STATUS -ne 0 ]; then + log_warning "Es laufen weiterhin nicht alle Systemdienste" +fi + +# Final step: Summary +log_section "REPARATUR ABGESCHLOSSEN" + +log_message "Die All-in-One Reparatur wurde abgeschlossen." + +if [ "$AUTO_MODE" = true ]; then + log_success "Automatischer Reparaturmodus: Alle erkannten Probleme wurden behoben" + log_message "Ein detaillierter Bericht wurde in logs/auto_fix_report.log gespeichert" + + # Restart services automatically in auto mode + if command -v systemctl >/dev/null 2>&1; then + log_message "Starte Dienste automatisch neu..." + + # Restart MongoDB if it was stopped + if ! systemctl is-active --quiet mongodb 2>/dev/null; then + systemctl restart mongodb 2>/dev/null + fi + + # Restart Inventarsystem services + if systemctl list-unit-files | grep -q inventarsystem-gunicorn.service; then + systemctl restart inventarsystem-gunicorn.service 2>/dev/null + fi + + if systemctl list-unit-files | grep -q inventarsystem-nginx.service; then + systemctl restart inventarsystem-nginx.service 2>/dev/null + fi + + log_success "Dienste wurden neu gestartet" + fi +else + log_message "Nächste Schritte:" + log_message "1. Starten Sie das System neu mit: sudo ./restart.sh" + log_message "2. Überprüfen Sie die Log-Dateien im logs-Verzeichnis" + log_message "3. Bei anhaltenden Problemen versuchen Sie: sudo ./rebuild-venv.sh" + log_message "" + log_message "Tipp: Um dieses Skript automatisch auszuführen, verwenden Sie:" + log_message "sudo ./fix-all.sh --setup-cron" +fi + +log_success "FERTIG" + +exit 0 diff --git a/init-admin.sh b/init-admin.sh new file mode 100755 index 0000000..6e77781 --- /dev/null +++ b/init-admin.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -euo pipefail + +# Initialize first admin user if database is empty +# Usage: ./init-admin.sh [username] [password] [first_name] [last_name] +# Default: admin / admin123456 / Admin / User + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +USERNAME="${1:-admin}" +PASSWORD="${2:-admin123456}" +FIRST_NAME="${3:-Admin}" +LAST_NAME="${4:-User}" + +# Wait for MongoDB to be ready +echo "Waiting for MongoDB to be ready..." +for i in {1..30}; do + if docker exec inventarsystem-mongodb mongosh --eval "db.adminCommand('ping')" >/dev/null 2>&1; then + echo "MongoDB is ready." + break + fi + if [ $i -eq 30 ]; then + echo "Error: MongoDB did not start in time." + exit 1 + fi + sleep 1 +done + +# Check if any users already exist +USER_COUNT=$(docker exec inventarsystem-mongodb mongosh --quiet --eval "db.users.countDocuments({})" Inventarsystem 2>/dev/null || echo "0") + +if [ "$USER_COUNT" -eq 0 ]; then + echo "Database is empty. Creating initial admin user: $USERNAME" + + # Hash the password + PASSWORD_HASH=$(python3 -c "import hashlib; print(hashlib.sha512(b'$PASSWORD').hexdigest())") + + # Insert admin user + docker exec inventarsystem-mongodb mongosh --eval " + db.users.insertOne({ + Username: '$USERNAME', + Password: '$PASSWORD_HASH', + Admin: true, + active_ausleihung: null, + name: '$FIRST_NAME', + last_name: '$LAST_NAME', + favorites: [] + }) + " Inventarsystem + + echo "✓ Admin user '$USERNAME' created successfully." + echo " Username: $USERNAME" + echo " Password: $PASSWORD" +else + echo "Database already has $USER_COUNT user(s). Skipping initialization." +fi diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f7da7c0 --- /dev/null +++ b/install.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="/opt/Inventarsystem" +REPO_SLUG="AIIrondev/Inventarsystem" +API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest" +BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz" +APP_IMAGE_ASSET_PREFIX="inventarsystem-image-" +LEGACY_DB_NAME="Inventarsystem" +LEGACY_MONGO_URI="mongodb://127.0.0.1:27017" +MIGRATE_LEGACY_DB=false +REMOVE_LEGACY_SYSTEM=false +LEGACY_SERVICE_CLEANUP=true +LEGACY_SYSTEM_DIR="" +LEGACY_BACKUP_ARCHIVE="" +CLEANUP_OLD_SERVICES=true +CLEANUP_OLD_REMOVE_CRON=false + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Error: missing command: $1" + exit 1 + fi +} + +install_docker_if_missing() { + if command -v docker >/dev/null 2>&1; then + return 0 + fi + + echo "Installing Docker..." + sudo apt-get update + sudo apt-get install -y docker.io docker-compose-v2 curl python3 + sudo systemctl enable --now docker +} + +usage() { + cat < Legacy database name (default: $LEGACY_DB_NAME) + --legacy-mongo-uri Legacy Mongo URI (default: $LEGACY_MONGO_URI) + --legacy-system-dir Optional old system directory to remove after migration + -h, --help Show this help message +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --migrate-legacy-db) + MIGRATE_LEGACY_DB=true + shift + ;; + --remove-legacy-system) + REMOVE_LEGACY_SYSTEM=true + shift + ;; + --skip-cleanup-old) + CLEANUP_OLD_SERVICES=false + shift + ;; + --cleanup-old-remove-cron) + CLEANUP_OLD_REMOVE_CRON=true + shift + ;; + --legacy-db-name) + LEGACY_DB_NAME="$2" + shift 2 + ;; + --legacy-mongo-uri) + LEGACY_MONGO_URI="$2" + shift 2 + ;; + --legacy-system-dir) + LEGACY_SYSTEM_DIR="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown option '$1'" + usage + exit 2 + ;; + esac + done +} + +install_mongo_tools_if_missing() { + if command -v mongodump >/dev/null 2>&1 && command -v mongorestore >/dev/null 2>&1; then + return 0 + fi + + echo "Installing MongoDB database tools..." + sudo apt-get update + sudo apt-get install -y mongodb-database-tools || sudo apt-get install -y mongo-tools +} + +backup_legacy_database() { + local backup_dir timestamp archive collection_count + + if [ "$MIGRATE_LEGACY_DB" != "true" ]; then + return 0 + fi + + install_mongo_tools_if_missing + + if ! command -v mongosh >/dev/null 2>&1; then + echo "Warning: mongosh not found, skipping legacy DB migration" + MIGRATE_LEGACY_DB=false + return 0 + fi + + if ! mongosh --quiet "$LEGACY_MONGO_URI/$LEGACY_DB_NAME" --eval "db.runCommand({ping:1}).ok" >/dev/null 2>&1; then + echo "No legacy MongoDB reachable at $LEGACY_MONGO_URI, skipping migration" + MIGRATE_LEGACY_DB=false + return 0 + fi + + collection_count="$(mongosh --quiet "$LEGACY_MONGO_URI/$LEGACY_DB_NAME" --eval "db.getCollectionNames().length" 2>/dev/null || echo 0)" + if [ "${collection_count:-0}" -eq 0 ]; then + echo "Legacy DB '$LEGACY_DB_NAME' has no collections, skipping migration" + MIGRATE_LEGACY_DB=false + return 0 + fi + + backup_dir="$PROJECT_DIR/backups/legacy-migration" + timestamp="$(date +%Y-%m-%d_%H-%M-%S)" + archive="$backup_dir/${LEGACY_DB_NAME}-${timestamp}.archive.gz" + + sudo mkdir -p "$backup_dir" + echo "Creating legacy MongoDB backup: $archive" + mongodump --uri "$LEGACY_MONGO_URI" --db "$LEGACY_DB_NAME" --archive="$archive" --gzip + + if [ ! -s "$archive" ]; then + echo "Error: legacy backup archive was not created" + exit 1 + fi + + LEGACY_BACKUP_ARCHIVE="$archive" +} + +restore_legacy_backup_into_docker() { + local i + + if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then + return 0 + fi + + echo "Waiting for Docker MongoDB to become ready..." + for i in $(seq 1 60); do + if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then + break + fi + sleep 2 + done + + if [ "$i" -eq 60 ]; then + echo "Error: Docker MongoDB did not become ready in time" + exit 1 + fi + + echo "Importing legacy backup into Docker MongoDB..." + sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE" + echo "Legacy DB import completed." +} + +cleanup_legacy_system() { + if [ "$REMOVE_LEGACY_SYSTEM" != "true" ] || [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then + return 0 + fi + + echo "Cleaning up old host MongoDB/system..." + + if [ "$LEGACY_SERVICE_CLEANUP" = "true" ] && command -v systemctl >/dev/null 2>&1; then + sudo systemctl stop mongod >/dev/null 2>&1 || true + sudo systemctl stop mongodb >/dev/null 2>&1 || true + sudo systemctl disable mongod >/dev/null 2>&1 || true + sudo systemctl disable mongodb >/dev/null 2>&1 || true + fi + + sudo rm -rf /var/lib/mongodb /var/log/mongodb 2>/dev/null || true + sudo apt-get purge -y mongodb mongodb-server mongodb-org mongodb-org-server mongodb-org-shell mongodb-org-mongos mongodb-database-tools mongo-tools >/dev/null 2>&1 || true + sudo apt-get autoremove -y >/dev/null 2>&1 || true + + if [ -n "$LEGACY_SYSTEM_DIR" ] && [ -d "$LEGACY_SYSTEM_DIR" ] && [ "$LEGACY_SYSTEM_DIR" != "$PROJECT_DIR" ]; then + echo "Removing legacy system directory: $LEGACY_SYSTEM_DIR" + sudo rm -rf "$LEGACY_SYSTEM_DIR" + fi + + echo "Legacy cleanup complete." +} + +cleanup_old_services() { + local cleanup_script + cleanup_script="$PROJECT_DIR/cleanup-old.sh" + + if [ "$CLEANUP_OLD_SERVICES" != "true" ]; then + return 0 + fi + + if [ ! -x "$cleanup_script" ]; then + echo "Warning: cleanup script not found at $cleanup_script, skipping old-system service cleanup" + return 0 + fi + + echo "Cleaning up old services/processes..." + if [ "$CLEANUP_OLD_REMOVE_CRON" = "true" ]; then + sudo bash "$cleanup_script" --remove-cron || true + else + sudo bash "$cleanup_script" || true + fi +} + +latest_tag_and_bundle_url() { + local meta_file + meta_file="$1" + + curl -fsSL "$API_URL" -o "$meta_file" + + python3 - <<'PY' "$meta_file" "$BUNDLE_ASSET" +import json, sys +meta_file, asset_name = sys.argv[1], sys.argv[2] +with open(meta_file, 'r', encoding='utf-8') as f: + data = json.load(f) +tag = data.get('tag_name', '').strip() +url = '' +image_url = '' +for asset in data.get('assets', []): + if asset.get('name') == asset_name: + url = asset.get('browser_download_url', '').strip() + break +for asset in data.get('assets', []): + if asset.get('name') == f'inventarsystem-image-{tag}.tar.gz': + image_url = asset.get('browser_download_url', '').strip() + break +print(tag) +print(url) +print(image_url) +PY +} + +main() { + parse_args "$@" + + install_docker_if_missing + need_cmd docker + need_cmd tar + need_cmd python3 + need_cmd curl + + local tmp_dir meta_file tag bundle_url image_url + tmp_dir="$(mktemp -d)" + meta_file="$tmp_dir/release.json" + trap 'rm -rf "$tmp_dir"' EXIT + + mapfile -t release_info < <(latest_tag_and_bundle_url "$meta_file") + tag="${release_info[0]:-}" + bundle_url="${release_info[1]:-}" + image_url="${release_info[2]:-}" + + if [ -z "$tag" ] || [ -z "$bundle_url" ]; then + echo "Error: latest release metadata is incomplete." + echo "Expected release asset: $BUNDLE_ASSET" + exit 1 + fi + + echo "Installing Inventarsystem release $tag into $PROJECT_DIR" + sudo mkdir -p "$PROJECT_DIR" + + curl -fL "$bundle_url" -o "$tmp_dir/$BUNDLE_ASSET" + sudo tar -xzf "$tmp_dir/$BUNDLE_ASSET" -C "$PROJECT_DIR" + + if [ -z "$image_url" ]; then + echo "Error: release image asset is missing" + exit 1 + fi + + curl -fL "$image_url" -o "$tmp_dir/inventarsystem-image-$tag.tar.gz" + sudo docker load -i "$tmp_dir/inventarsystem-image-$tag.tar.gz" >/dev/null + + if [ ! -f "$PROJECT_DIR/start.sh" ]; then + echo "Error: release bundle is missing start.sh" + exit 1 + fi + if [ ! -f "$PROJECT_DIR/stop.sh" ]; then + echo "Error: release bundle is missing stop.sh" + exit 1 + fi + if [ ! -f "$PROJECT_DIR/restart.sh" ]; then + cat > "$tmp_dir/restart.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$SCRIPT_DIR/stop.sh" +"$SCRIPT_DIR/start.sh" +EOF + sudo install -m 755 "$tmp_dir/restart.sh" "$PROJECT_DIR/restart.sh" + fi + + if [ ! -f "$PROJECT_DIR/cleanup-old.sh" ] && [ -f "$INSTALLER_DIR/cleanup-old.sh" ]; then + sudo install -m 755 "$INSTALLER_DIR/cleanup-old.sh" "$PROJECT_DIR/cleanup-old.sh" + fi + + sudo chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" + [ -f "$PROJECT_DIR/cleanup-old.sh" ] && sudo chmod +x "$PROJECT_DIR/cleanup-old.sh" + + echo "$tag" | sudo tee "$PROJECT_DIR/.release-version" >/dev/null + + if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then + cat > "$tmp_dir/.docker-build.env" <&2; } + +usage() { + cat < [--restart] [--force] Persistently lock to a commit/tag/branch and deploy it + $0 use [--restart] [--force] Use ref once (no persistence) and deploy it + $0 clear [--restart] [--force] Remove lock, switch back to main, pull latest + $0 apply [--restart] [--force] Re-apply the current lock (if any) + $0 status Show current commit and lock state + $0 list [--tags|--commits] List tags or recent commits (default: tags) + +Notes: + - can be a tag name, branch name, or full/short commit hash + - --restart calls ./restart.sh after switching + - --force discards local changes (git reset --hard) if needed +USAGE +} + +ensure_git_ready() { + # Avoid "dubious ownership" errors + git config --global --add safe.directory "$PROJECT_DIR" >/dev/null 2>&1 || true + # Fetch refs and tags + git fetch --all --tags --prune +} + +ensure_clean_or_force() { + if ! git diff --quiet || ! git diff --cached --quiet; then + if [ "$FORCE" = true ]; then + log "Discarding local changes (force)" + git reset --hard + git clean -fdx >/dev/null 2>&1 || true + else + err "Working tree has local changes. Re-run with --force to discard." + exit 2 + fi + fi +} + +restart_if_requested() { + if [ "$RESTART" = true ]; then + if [ -x "$PROJECT_DIR/restart.sh" ]; then + log "Restarting services..." + "$PROJECT_DIR/restart.sh" + else + log "restart.sh not found or not executable; skipping restart" + fi + fi +} + +# Create a full backup before switching versions (files + DB CSVs) +LAST_BACKUP_ARCHIVE="" +LAST_BACKUP_DIRNAME="" +create_pre_switch_backup() { + local ts backup_name backup_dir backup_archive + ts=$(date +"%Y-%m-%d_%H-%M-%S") + backup_name="Inventarsystem-pre-switch-$ts" + backup_dir="$BACKUP_BASE_DIR/$backup_name" + backup_archive="$BACKUP_BASE_DIR/$backup_name.tar.gz" + + log "Creating pre-switch backup at $backup_archive" + sudo mkdir -p "$backup_dir" || { err "Failed to create backup directory"; return 1; } + + # Copy project files (exclude .venv to reduce size, keep .git for reference) + log "Copying project files to backup directory..." + sudo rsync -a --delete \ + --exclude='.venv' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + "$PROJECT_DIR/" "$backup_dir/" || log "Warning: rsync file copy encountered issues" + + # Database CSV backup using existing helper + log "Backing up MongoDB to CSVs..." + sudo mkdir -p "$backup_dir/mongodb_backup" || true + if [ -x "$PROJECT_DIR/run-backup.sh" ]; then + if sudo -E "$PROJECT_DIR/run-backup.sh" --db Inventarsystem --uri mongodb://localhost:27017/ --out "$backup_dir/mongodb_backup" >> "$VM_LOG" 2>&1; then + log "Database backup completed" + else + err "Database backup failed; continuing with file backup only" + fi + else + log "run-backup.sh not found; skipping DB backup" + fi + + # Compress backup + log "Compressing backup archive..." + if sudo tar -czf "$backup_archive" -C "$BACKUP_BASE_DIR" "$backup_name"; then + log "Backup archived to $backup_archive" + sudo rm -rf "$backup_dir" || true + # Set readable permissions + sudo chmod 644 "$backup_archive" || true + # Export for subsequent restore step + LAST_BACKUP_ARCHIVE="$backup_archive" + LAST_BACKUP_DIRNAME="$backup_name" + else + err "Failed to create compressed backup archive" + return 1 + fi +} + +# Restore key data from the backup archive into current working tree +restore_from_backup_archive() { + if [ -z "$LAST_BACKUP_ARCHIVE" ] || [ ! -f "$LAST_BACKUP_ARCHIVE" ]; then + log "No backup archive available to restore data from; skipping data restore" + return 0 + fi + local tmpdir + tmpdir=$(mktemp -d /tmp/inventarsystem_restore_XXXXXX) + log "Extracting backup archive $LAST_BACKUP_ARCHIVE to $tmpdir" + if ! sudo tar -xzf "$LAST_BACKUP_ARCHIVE" -C "$tmpdir"; then + err "Failed to extract backup archive; skipping data restore" + rm -rf "$tmpdir" || true + return 1 + fi + local src_root="$tmpdir/$LAST_BACKUP_DIRNAME" + # Some backups might extract without top-level dir; fallback + if [ ! -d "$src_root" ]; then + src_root="$tmpdir" + fi + log "Restoring data directories from backup..." + for rel in "${DATA_PATHS[@]}"; do + if [ -e "$src_root/$rel" ]; then + mkdir -p "$PROJECT_DIR/$(dirname "$rel")" 2>/dev/null || true + rsync -a "$src_root/$rel" "$PROJECT_DIR/$(dirname "$rel")/" >> "$VM_LOG" 2>&1 || log "Warning: failed to restore $rel" + log "Restored: $rel" + else + log "Not in backup (skipped): $rel" + fi + done + rm -rf "$tmpdir" || true +} + +# Ensure permissions on critical data directories +ensure_permissions() { + # Logs should be writable + mkdir -p "$PROJECT_DIR/logs" && chmod 777 "$PROJECT_DIR/logs" 2>/dev/null || true + # Web data directories readable/executable by server + for d in "$PROJECT_DIR/Web/uploads" "$PROJECT_DIR/Web/thumbnails" "$PROJECT_DIR/Web/QRCodes" "$PROJECT_DIR/Web/previews" "$PROJECT_DIR/Images"; do + [ -d "$d" ] && chmod -R 755 "$d" 2>/dev/null || true + done + # SSL cert perms (key must be 600) + if [ -f "$PROJECT_DIR/certs/inventarsystem.key" ]; then + chmod 600 "$PROJECT_DIR/certs/inventarsystem.key" 2>/dev/null || true + fi +} + +checkout_ref() { + local ref="$1" + + # Try tag + if git show-ref --verify --quiet "refs/tags/$ref"; then + git checkout -B locked-tag-"$ref" "tags/$ref" + return 0 + fi + # Try remote branch + if git show-ref --verify --quiet "refs/remotes/origin/$ref"; then + git checkout -B locked-branch-"$ref" "origin/$ref" + return 0 + fi + # Try local branch + if git show-ref --verify --quiet "refs/heads/$ref"; then + git checkout "$ref" + return 0 + fi + # Try commit-ish + if git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then + git checkout -B locked-commit "$ref" + return 0 + fi + + err "Could not resolve ref '$ref' to tag/branch/commit" + exit 3 +} + +deploy_main_latest() { + ensure_clean_or_force + if git show-ref --verify --quiet refs/heads/main; then + git checkout main + else + git checkout -B main origin/main + fi + git pull --ff-only +} + +cmd_status() { + local head_commit + head_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + echo "Current commit: $head_commit" + if [ -f "$LOCK_FILE" ]; then + echo "Version lock: $(cat "$LOCK_FILE")" + else + echo "Version lock: none (tracking main)" + fi +} + +cmd_list() { + local mode="tags" + for a in "$@"; do + case "$a" in + --tags) mode="tags" ;; + --commits) mode="commits" ;; + esac + done + ensure_git_ready + if [ "$mode" = "tags" ]; then + git tag --list | sort -V + else + git --no-pager log --oneline -n 30 + fi +} + +cmd_pin() { + local ref="$1" + ensure_git_ready + # Always produce a backup before switching + create_pre_switch_backup || log "Backup step had issues; proceeding with caution" + ensure_clean_or_force + echo "$ref" > "$LOCK_FILE" + checkout_ref "$ref" + # Restore data from the backup archive + restore_from_backup_archive + ensure_permissions + log "Pinned to: $ref (commit $(git rev-parse --short HEAD))" + restart_if_requested +} + +cmd_use() { + local ref="$1" + ensure_git_ready + create_pre_switch_backup || log "Backup step had issues; proceeding with caution" + ensure_clean_or_force + checkout_ref "$ref" + restore_from_backup_archive + ensure_permissions + log "Using (one-time): $ref (commit $(git rev-parse --short HEAD))" + restart_if_requested +} + +cmd_clear() { + ensure_git_ready + create_pre_switch_backup || log "Backup step had issues; proceeding with caution" + [ -f "$LOCK_FILE" ] && rm -f "$LOCK_FILE" + deploy_main_latest + restore_from_backup_archive + ensure_permissions + log "Cleared version lock; now on main @ $(git rev-parse --short HEAD)" + restart_if_requested +} + +cmd_apply() { + ensure_git_ready + create_pre_switch_backup || log "Backup step had issues; proceeding with caution" + if [ -f "$LOCK_FILE" ]; then + ensure_clean_or_force + local ref + ref=$(cat "$LOCK_FILE") + checkout_ref "$ref" + log "Applied lock: $ref (commit $(git rev-parse --short HEAD))" + else + log "No lock present; keeping main" + deploy_main_latest + fi + restore_from_backup_archive + ensure_permissions + restart_if_requested +} + +# Parse global flags that can appear after subcommand too +parse_tail_flags() { + for a in "$@"; do + case "$a" in + --restart) RESTART=true ;; + --force) FORCE=true ;; + esac + done +} + +main() { + local cmd="${1:-}"; shift || true + case "$cmd" in + pin) + [ $# -ge 1 ] || { usage; exit 1; } + local ref="$1"; shift; parse_tail_flags "$@"; cmd_pin "$ref" ;; + use) + [ $# -ge 1 ] || { usage; exit 1; } + local ref="$1"; shift; parse_tail_flags "$@"; cmd_use "$ref" ;; + clear) + parse_tail_flags "$@"; cmd_clear ;; + apply) + parse_tail_flags "$@"; cmd_apply ;; + status) + cmd_status ;; + list) + cmd_list "$@" ;; + --help|-h|help|"") + usage ;; + *) + err "Unknown command: $cmd"; usage; exit 1 ;; + esac +} + +main "$@" diff --git a/rebuild-venv.sh b/rebuild-venv.sh new file mode 100755 index 0000000..b5a3687 --- /dev/null +++ b/rebuild-venv.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +# This script completely rebuilds the virtual environment +# It should be run as root/sudo + +# Get the script directory +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +VENV_DIR="$SCRIPT_DIR/.venv" +BACKUP_DIR="$SCRIPT_DIR/.venv_backup_$(date +%Y%m%d%H%M%S)" + +# Function to log messages +log_message() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +log_message "Starting virtual environment rebuild" + +# Backup existing virtual environment if it exists +if [ -d "$VENV_DIR" ]; then + log_message "Backing up existing virtual environment to $BACKUP_DIR" + mv "$VENV_DIR" "$BACKUP_DIR" || { + log_message "ERROR: Failed to backup existing virtual environment. Proceeding without backup." + rm -rf "$VENV_DIR" + } +fi + +# Create logs directory with proper permissions +log_message "Ensuring logs directory exists with proper permissions" +mkdir -p "$SCRIPT_DIR/logs" +chmod 777 "$SCRIPT_DIR/logs" || log_message "WARNING: Could not set permissions on logs directory" + +# Create a new virtual environment +log_message "Creating new virtual environment" +python3 -m venv "$VENV_DIR" || { + log_message "ERROR: Failed to create virtual environment. Trying with system packages..." + python3 -m venv "$VENV_DIR" --system-site-packages || { + log_message "ERROR: Failed to create virtual environment even with system packages. Exiting." + exit 1 + } +} + +# Set proper permissions +log_message "Setting permissions for new virtual environment" +chmod -R 755 "$VENV_DIR" || log_message "WARNING: Could not set permissions on virtual environment" +find "$VENV_DIR" -type d -exec chmod 755 {} \; 2>/dev/null +find "$VENV_DIR/bin" -type f -exec chmod +x {} \; 2>/dev/null + +# Activate the virtual environment +log_message "Activating virtual environment" +source "$VENV_DIR/bin/activate" || { + log_message "ERROR: Failed to activate virtual environment" + exit 1 +} + +# Install packages +log_message "Upgrading pip" +pip install --upgrade pip || log_message "WARNING: Failed to upgrade pip" + +log_message "Installing pymongo" +# First ensure bson is removed to avoid conflicts +pip uninstall -y bson || log_message "WARNING: Failed to uninstall bson (may not exist)" +# Check if bson directory exists after uninstall and remove it if necessary +if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then + log_message "Force removing bson directory" + rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson" +fi +# Now install pymongo +pip install pymongo==4.6.3 || log_message "WARNING: Failed to install pymongo" +# Verify pymongo installation +python -c "import pymongo; print(f'PyMongo version: {pymongo.__version__}')" && log_message "✓ PyMongo installed correctly" || log_message "WARNING: PyMongo verification failed" + +# Install other requirements if they exist +if [ -f "$SCRIPT_DIR/requirements.txt" ]; then + log_message "Installing packages from requirements.txt (excluding bson)" + # Create a modified requirements file without bson + grep -v "^bson" "$SCRIPT_DIR/requirements.txt" > "$SCRIPT_DIR/requirements_filtered.txt" + # Also remove pymongo since we already installed it + grep -v "^pymongo" "$SCRIPT_DIR/requirements_filtered.txt" > "$SCRIPT_DIR/requirements_no_conflicts.txt" + pip install -r "$SCRIPT_DIR/requirements_no_conflicts.txt" || log_message "WARNING: Failed to install some packages from requirements.txt" + rm -f "$SCRIPT_DIR/requirements_filtered.txt" "$SCRIPT_DIR/requirements_no_conflicts.txt" + + log_message "Verifying pymongo installation after package installation..." + # Check if the bson directory from the standalone package still exists and remove it + if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then + log_message "Found standalone bson package, removing it..." + rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson" + # Reinstall pymongo to ensure it's correctly configured + pip install --force-reinstall pymongo==4.6.3 + fi +else + log_message "No requirements.txt found, installing essential packages manually" + pip install flask werkzeug gunicorn pillow qrcode apscheduler python-dateutil pytz requests || { + log_message "WARNING: Failed to install some essential packages" + } +fi + +# Deactivate virtual environment +deactivate + +# Verify the virtual environment works correctly +log_message "Verifying virtual environment..." +if "$VENV_DIR/bin/python" -c "import pymongo, flask, gunicorn, requests, PIL, apscheduler; exit(0)" 2>/dev/null; then + log_message "✓ Virtual environment verification successful" + + # Clean up backup if everything works + if [ -d "$BACKUP_DIR" ]; then + log_message "Removing backup virtual environment since verification was successful" + rm -rf "$BACKUP_DIR" + log_message "✓ Backup environment removed" + fi +else + log_message "WARNING: Virtual environment verification failed" + log_message "Keeping backup at $BACKUP_DIR in case it's needed for recovery" +fi + +log_message "Virtual environment rebuild complete" +log_message "You can now run the restart.sh script" + +exit 0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ba666f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +flask +werkzeug +gunicorn +pymongo==4.6.3 +pillow +qrcode +apscheduler +python-dateutil +pytz +requests +reportlab +python-barcode \ No newline at end of file diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..c602c80 --- /dev/null +++ b/restart.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +"$SCRIPT_DIR/stop.sh" +"$SCRIPT_DIR/start.sh" \ No newline at end of file diff --git a/restore.sh b/restore.sh new file mode 100755 index 0000000..c744974 --- /dev/null +++ b/restore.sh @@ -0,0 +1,527 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_DIR="$SCRIPT_DIR/logs" +LOG_FILE="$LOG_DIR/restore.log" +WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)" + +DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}" +SOURCE_PATH="" +BACKUP_DATE="" +DROP_DATABASE=false +RESTART_SERVICES=false +STAGED_PATH="" + +BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups" +BACKUP_ROOT_SYSTEM="/var/backups" + +SUDO="" +if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" +fi + +DOCKER_COMPOSE=() + +cleanup() { + rm -rf "$WORK_DIR" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +mkdir -p "$LOG_DIR" + +log() { + local msg="$1" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $msg" | tee -a "$LOG_FILE" +} + +show_help() { + cat < [options] + $0 --date [options] + $0 --list + +Options: + --source Backup source path. + Supported: + - old folder with CSV files + - old Inventarsystem-*.tar.gz backup + - new mongodb-*.archive.gz backup + - folder containing mongodb-*.archive.gz + --date Resolve source from known backup locations. + - YYYY-MM-DD: checks backups/YYYY-MM-DD and /var/backups/Inventarsystem-YYYY-MM-DD(.tar.gz) + - latest: newest from local/system backup roots + --drop-database Drop target DB before import (recommended for full restore) + --restart-services Restart stack after restore + --list List detected backup candidates + --help Show this help + +Notes: + - Always restores into current DB name: $DB_NAME + - Supports both legacy CSV and new archive backups + - Normalizes collection names to current structure (users, items, ausleihungen, filter_presets, settings) +EOF +} + +setup_compose() { + if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + DOCKER_COMPOSE=(docker compose) + return 0 + fi + + if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then + DOCKER_COMPOSE=($SUDO docker compose) + return 0 + fi + + log "ERROR: docker compose is not available" + exit 1 +} + +compose() { + "${DOCKER_COMPOSE[@]}" "$@" +} + +list_backups() { + echo "Detected backup candidates:" + + if [ -d "$BACKUP_ROOT_LOCAL" ]; then + find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort + fi + + if [ -d "$BACKUP_ROOT_SYSTEM" ]; then + find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort + fi +} + +resolve_latest_source() { + local latest + latest="$({ + if [ -d "$BACKUP_ROOT_LOCAL" ]; then + find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null + fi + if [ -d "$BACKUP_ROOT_SYSTEM" ]; then + find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null + fi + } | sort -nr | head -n1 | cut -d' ' -f2-)" + + if [ -z "$latest" ]; then + log "ERROR: no backup candidates found" + exit 1 + fi + + SOURCE_PATH="$latest" +} + +resolve_date_source() { + local d="$1" + + if [ "$d" = "latest" ]; then + resolve_latest_source + return + fi + + local p1="$BACKUP_ROOT_LOCAL/$d" + local p2="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d" + local p3="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d.tar.gz" + + if [ -e "$p1" ]; then + SOURCE_PATH="$p1" + elif [ -e "$p2" ]; then + SOURCE_PATH="$p2" + elif [ -e "$p3" ]; then + SOURCE_PATH="$p3" + else + log "ERROR: no backup found for date $d" + exit 1 + fi +} + +ensure_stack() { + log "Ensuring mongodb/app services are running..." + compose up -d mongodb app >/dev/null + + if ! compose ps --status running mongodb | grep -q mongodb; then + log "ERROR: mongodb container is not running" + exit 1 + fi + + if ! compose ps --status running app | grep -q app; then + log "ERROR: app container is not running" + exit 1 + fi +} + +stage_source() { + local src="$1" + + if [ ! -e "$src" ]; then + log "ERROR: source path does not exist: $src" + exit 1 + fi + + if [ -f "$src" ] && [[ "$src" == *.tar.gz ]]; then + log "Extracting tar backup: $src" + tar -xzf "$src" -C "$WORK_DIR" + STAGED_PATH="$WORK_DIR" + return + fi + + if [ -f "$src" ]; then + cp -f "$src" "$WORK_DIR/" + STAGED_PATH="$WORK_DIR" + return + fi + + if [ -d "$src" ]; then + STAGED_PATH="$src" + return + fi + + log "ERROR: unsupported source type: $src" + exit 1 +} + +find_archive_file() { + local root="$1" + find "$root" -type f \( -name "*.archive.gz" -o -name "*.archive" \) 2>/dev/null | head -n1 || true +} + +find_best_csv_dir() { + local root="$1" + local best_dir="" + local best_count=0 + + while IFS= read -r dir; do + [ -z "$dir" ] && continue + local count + count="$(find "$dir" -maxdepth 1 -type f -name '*.csv' | wc -l | tr -d ' ')" + if [ "$count" -gt "$best_count" ]; then + best_count="$count" + best_dir="$dir" + fi + done < <(find "$root" -type f -name '*.csv' -printf '%h\n' 2>/dev/null | sort -u) + + if [ "$best_count" -gt 0 ]; then + echo "$best_dir" + fi +} + +normalize_structure() { + local py="$WORK_DIR/normalize_db.py" + + cat > "$py" <<'PY' +from pymongo import MongoClient +import os + +db_name = os.getenv("DB_NAME", "Inventarsystem") +host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb") +port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017")) + +client = MongoClient(host, port) +db = client[db_name] + +rename_map = { + "user": "users", + "item": "items", + "ausleihung": "ausleihungen", + "filter_preset": "filter_presets", + "preset": "filter_presets", + "setting": "settings", +} + +for old, new in rename_map.items(): + if old not in db.list_collection_names(): + continue + + if new not in db.list_collection_names(): + db[old].rename(new) + print(f"Renamed collection {old} -> {new}") + continue + + moved = 0 + for doc in db[old].find({}): + db[new].insert_one(doc) + moved += 1 + db[old].drop() + print(f"Merged {moved} docs from {old} into {new}") + +print("Final collections:", sorted(db.list_collection_names())) +PY + + local app_cid + app_cid="$(compose ps -q app)" + + $SUDO docker cp "$py" "$app_cid:/tmp/normalize_db.py" + compose exec -T app env DB_NAME="$DB_NAME" python /tmp/normalize_db.py +} + +import_archive() { + local archive_file="$1" + local mongo_cid + + mongo_cid="$(compose ps -q mongodb)" + if [ -z "$mongo_cid" ]; then + log "ERROR: could not resolve mongodb container id" + exit 1 + fi + + log "Importing archive backup: $archive_file" + $SUDO docker cp "$archive_file" "$mongo_cid:/tmp/restore.archive.gz" + + local drop_flag="" + if [ "$DROP_DATABASE" = true ]; then + drop_flag="--drop" + fi + + compose exec -T mongodb sh -lc "mongorestore --archive=/tmp/restore.archive.gz --gzip $drop_flag" + compose exec -T mongodb rm -f /tmp/restore.archive.gz >/dev/null 2>&1 || true + + normalize_structure +} + +import_csv() { + local csv_dir="$1" + local app_cid py + + app_cid="$(compose ps -q app)" + if [ -z "$app_cid" ]; then + log "ERROR: could not resolve app container id" + exit 1 + fi + + log "Importing CSV backup directory: $csv_dir" + compose exec -T app sh -lc "rm -rf /tmp/restore_csv && mkdir -p /tmp/restore_csv" + $SUDO docker cp "$csv_dir/." "$app_cid:/tmp/restore_csv" + + py="$WORK_DIR/import_csv.py" + cat > "$py" <<'PY' +import os +import csv +import ast +import re +from pymongo import MongoClient +from bson.objectid import ObjectId + +DB_NAME = os.getenv("DB_NAME", "Inventarsystem") +CSV_DIR = os.getenv("CSV_DIR", "/tmp/restore_csv") +DROP_DATABASE = os.getenv("DROP_DATABASE", "false").lower() == "true" +HOST = os.getenv("INVENTAR_MONGODB_HOST", "mongodb") +PORT = int(os.getenv("INVENTAR_MONGODB_PORT", "27017")) + +COLLECTION_MAP = { + "users": "users", + "user": "users", + "items": "items", + "item": "items", + "ausleihungen": "ausleihungen", + "ausleihung": "ausleihungen", + "filter_presets": "filter_presets", + "filter_preset": "filter_presets", + "settings": "settings", + "setting": "settings", +} + +BOOL_FIELDS = {"Admin", "Verfuegbar", "enabled"} +INT_FIELDS = {"Anschaffungskosten", "filter_num"} +OID_LIKE_FIELDS = {"_id", "Item"} + +client = MongoClient(HOST, PORT) +db = client[DB_NAME] + +if DROP_DATABASE: + client.drop_database(DB_NAME) + db = client[DB_NAME] + + +def parse_scalar(key, value): + if value is None: + return None + + value = value.strip() + if value == "": + return None + + if key in BOOL_FIELDS: + lowered = value.lower() + if lowered == "true": + return True + if lowered == "false": + return False + + if key in INT_FIELDS: + try: + return int(value) + except Exception: + pass + + if key in OID_LIKE_FIELDS and re.fullmatch(r"[0-9a-fA-F]{24}", value): + return ObjectId(value) + + if value.startswith("[") and value.endswith("]"): + try: + return ast.literal_eval(value) + except Exception: + pass + + return value + + +def load_csv_file(csv_path): + docs = [] + with open(csv_path, "r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + doc = {} + for key, value in row.items(): + doc[key] = parse_scalar(key, value) + docs.append(doc) + return docs + + +csv_files = [ + os.path.join(CSV_DIR, f) + for f in sorted(os.listdir(CSV_DIR)) + if f.endswith(".csv") +] + +if not csv_files: + raise SystemExit(f"No CSV files found in {CSV_DIR}") + +for csv_file in csv_files: + base = os.path.splitext(os.path.basename(csv_file))[0] + coll = COLLECTION_MAP.get(base, base) + + docs = load_csv_file(csv_file) + db[coll].drop() + if docs: + db[coll].insert_many(docs) + print(f"Imported {len(docs)} docs into {coll} from {os.path.basename(csv_file)}") + +print("Collections after CSV import:", sorted(db.list_collection_names())) +PY + + $SUDO docker cp "$py" "$app_cid:/tmp/import_csv.py" + compose exec -T app env DB_NAME="$DB_NAME" CSV_DIR="/tmp/restore_csv" DROP_DATABASE="$DROP_DATABASE" python /tmp/import_csv.py + + normalize_structure +} + +print_counts() { + local py="$WORK_DIR/print_counts.py" + + cat > "$py" <<'PY' +from pymongo import MongoClient +import os + +db_name = os.getenv("DB_NAME", "Inventarsystem") +host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb") +port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017")) + +client = MongoClient(host, port) +db = client[db_name] + +for name in ["users", "items", "ausleihungen", "filter_presets", "settings"]: + try: + count = db[name].count_documents({}) + except Exception: + count = -1 + print(f"{name}: {count}") +PY + + local app_cid + app_cid="$(compose ps -q app)" + $SUDO docker cp "$py" "$app_cid:/tmp/print_counts.py" + + log "Post-restore collection counts:" + compose exec -T app env DB_NAME="$DB_NAME" python /tmp/print_counts.py | tee -a "$LOG_FILE" +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --source) + SOURCE_PATH="${2:-}" + shift 2 + ;; + --date) + BACKUP_DATE="${2:-}" + shift 2 + ;; + --drop-database) + DROP_DATABASE=true + shift + ;; + --restart-services) + RESTART_SERVICES=true + shift + ;; + --list) + list_backups + exit 0 + ;; + --help) + show_help + exit 0 + ;; + *) + echo "Unknown argument: $1" + show_help + exit 1 + ;; + esac + done +} + +main() { + parse_args "$@" + setup_compose + + if [ -n "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then + log "ERROR: use either --source or --date, not both" + exit 1 + fi + + if [ -z "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then + resolve_date_source "$BACKUP_DATE" + fi + + if [ -z "$SOURCE_PATH" ]; then + log "ERROR: provide --source or --date " + show_help + exit 1 + fi + + ensure_stack + + local staged archive_file csv_dir + stage_source "$SOURCE_PATH" + staged="$STAGED_PATH" + archive_file="$(find_archive_file "$staged")" + + if [ -n "$archive_file" ]; then + import_archive "$archive_file" + print_counts + else + csv_dir="$(find_best_csv_dir "$staged")" + if [ -z "$csv_dir" ]; then + log "ERROR: no supported backup data found under: $SOURCE_PATH" + log "Expected either *.archive.gz or CSV files" + exit 1 + fi + import_csv "$csv_dir" + print_counts + fi + + if [ "$RESTART_SERVICES" = true ]; then + log "Restarting services..." + "$SCRIPT_DIR/restart.sh" + fi + + log "Restore completed successfully into DB: $DB_NAME" +} + +main "$@" diff --git a/run-backup.sh b/run-backup.sh new file mode 100755 index 0000000..3ae792f --- /dev/null +++ b/run-backup.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Helper script to run the backup with proper Python environment + +# Get the script directory +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + +# Default values if no arguments provided +if [ $# -eq 0 ]; then + # Define default values + DB="Inventarsystem" + URI="mongodb://localhost:27017/" + OUT="$SCRIPT_DIR/backups/$(date +%Y-%m-%d)" + + echo "No arguments provided. Using defaults:" + echo " --uri $URI" + echo " --db $DB" + echo " --out $OUT" + + # Create output directory if it doesn't exist + mkdir -p "$OUT" + + # Set the arguments + ARGS="--uri $URI --db $DB --out $OUT" +else + # Use provided arguments + ARGS="$@" +fi + +# Check if we're in a virtual environment +if [ -f "$SCRIPT_DIR/.venv/bin/python" ]; then + # Check if we have execute permissions for the virtual environment Python + if [ -x "$SCRIPT_DIR/.venv/bin/python" ]; then + # Use the virtual environment's Python + "$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/Backup-DB.py" $ARGS || { + echo "Failed to execute with virtual environment Python. Trying system Python..." + python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS + } + else + echo "Virtual environment found but Python is not executable. Using system Python instead." + echo "To fix virtual environment permissions, run: sudo ./rebuild-venv.sh" + python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS + fi +else + # Use system Python + echo "No virtual environment found. Using system Python." + python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS +fi diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..8ead803 --- /dev/null +++ b/start.sh @@ -0,0 +1,574 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +ENV_FILE="$SCRIPT_DIR/.docker-build.env" +APP_IMAGE_REPO="ghcr.io/aiirondev/inventarsystem" +DIST_DIR="$SCRIPT_DIR/dist" + +SUDO="" +if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" +fi + +NUITKA_BUILD_VALUE="0" +HTTP_PORT_VALUE="80" +HTTPS_PORT_VALUE="443" +CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}" +APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/inventarsystem:latest}" + +usage() { + cat </dev/null 2>&1; then + return 0 + fi + + echo "Docker not found. Trying distro package docker.io..." + if apt_install docker.io; then + return 0 + fi + + echo "docker.io install failed. Trying Docker CE package docker-ce..." + apt_install ca-certificates curl gnupg lsb-release + + $SUDO install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg + $SUDO chmod a+r /etc/apt/keyrings/docker.gpg + + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null + + $SUDO apt-get update -y + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +} + +ensure_runtime_dependencies() { + local missing=() + + if ! command -v docker >/dev/null 2>&1; then + install_docker_engine + fi + + if ! docker compose version >/dev/null 2>&1; then + missing+=(docker-compose-v2) + fi + + if ! command -v openssl >/dev/null 2>&1; then + missing+=(openssl) + fi + + if ! command -v curl >/dev/null 2>&1; then + missing+=(curl) + fi + + if ! command -v python3 >/dev/null 2>&1; then + missing+=(python3) + fi + + if cron_setup_enabled && ! command -v crontab >/dev/null 2>&1; then + missing+=(cron) + fi + + if [ "${#missing[@]}" -gt 0 ]; then + echo "Installing missing dependencies: ${missing[*]}" + apt_install "${missing[@]}" + fi + + if command -v systemctl >/dev/null 2>&1; then + $SUDO systemctl enable --now docker >/dev/null 2>&1 || true + if cron_setup_enabled; then + $SUDO systemctl enable --now cron >/dev/null 2>&1 || true + fi + fi +} + +setup_boot_autostart_service() { + local service_path + local service_name="inventarsystem-docker.service" + + if [ "${INVENTAR_SKIP_AUTOSTART_SETUP:-0}" = "1" ]; then + return 0 + fi + + if ! command -v systemctl >/dev/null 2>&1; then + return 0 + fi + + service_path="/etc/systemd/system/$service_name" + + if [ ! -f "$service_path" ] || ! grep -Fq "$SCRIPT_DIR/start.sh --no-cron" "$service_path"; then + $SUDO tee "$service_path" >/dev/null </dev/null 2>&1 || true +} + +setup_scheduled_jobs() { + if ! cron_setup_enabled; then + echo "Cron job setup disabled (INVENTAR_SETUP_CRON=$CRON_SETUP_VALUE)" + return 0 + fi + + if ! command -v crontab >/dev/null 2>&1; then + echo "Warning: crontab not available, skipping nightly update setup" + return 0 + fi + + local update_line backup_line + update_line="0 3 * * * cd $SCRIPT_DIR && ./update.sh >> $SCRIPT_DIR/logs/update.log 2>&1" + backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1" + + local existing_cron + if [ "$(id -u)" -eq 0 ]; then + existing_cron="$(crontab -l 2>/dev/null || true)" + { + printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true + echo "$backup_line" + echo "$update_line" + } | crontab - + else + existing_cron="$($SUDO crontab -l 2>/dev/null || true)" + { + printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true + echo "$backup_line" + echo "$update_line" + } | $SUDO crontab - + fi + + echo "Nightly backup scheduled at 02:30" + echo "Nightly auto-update scheduled at 03:00" +} + +ensure_tls_certificates() { + local cert_dir cert_path key_path cn + cert_dir="$SCRIPT_DIR/certs" + cert_path="$cert_dir/inventarsystem.crt" + key_path="$cert_dir/inventarsystem.key" + + mkdir -p "$cert_dir" + + if [ -f "$cert_path" ] && [ -f "$key_path" ]; then + return 0 + fi + + cn="${TLS_CN:-localhost}" + echo "No TLS certificates found. Generating self-signed certificate for CN=$cn" + + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$key_path" \ + -out "$cert_path" \ + -subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1 + + chmod 600 "$key_path" + chmod 644 "$cert_path" +} + +ensure_nginx_config_mount_source() { + local nginx_dir config_path backup_path + nginx_dir="$SCRIPT_DIR/docker/nginx" + config_path="$nginx_dir/default.conf" + + mkdir -p "$nginx_dir" + + if [ -d "$config_path" ]; then + backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak" + mv "$config_path" "$backup_path" + echo "Warning: moved unexpected directory $config_path to $backup_path" + fi + + if [ ! -f "$config_path" ]; then + cat > "$config_path" <<'EOF' +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/inventarsystem.crt; + ssl_certificate_key /etc/nginx/certs/inventarsystem.key; + + client_max_body_size 50M; + + location / { + proxy_pass http://app:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + default_type text/html; + return 200 'Server Error

Server Error

The service is temporarily unavailable.

'; + } +} +EOF + echo "Recreated missing nginx config at $config_path" + fi +} + +ensure_app_image_loaded() { + if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then + return 0 + fi + + local image_archive + image_archive="$(find_local_dist_image_archive || true)" + if [ -n "$image_archive" ]; then + echo "Loading app image from local dist artifact: $image_archive" + if docker load -i "$image_archive" >/dev/null 2>&1 && docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then + return 0 + fi + echo "Warning: failed to load expected app image from $image_archive" + fi + + echo "Error: local app image not found: $APP_IMAGE_VALUE" + echo "Run ./update.sh so the nightly updater loads the release image first." + exit 1 +} + +find_local_dist_image_archive() { + local tag archive + + if [ ! -d "$DIST_DIR" ]; then + return 1 + fi + + tag="${APP_IMAGE_VALUE##*:}" + for archive in \ + "$DIST_DIR/inventarsystem-image-$tag.tar.gz" \ + "$DIST_DIR/inventarsystem-image-$tag.tar" \ + "$DIST_DIR/inventarsystem-image.tar.gz" \ + "$DIST_DIR/inventarsystem-image.tar"; do + if [ -f "$archive" ]; then + echo "$archive" + return 0 + fi + done + + archive="$(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) | sort | tail -n1)" + if [ -n "$archive" ]; then + echo "$archive" + return 0 + fi + + return 1 +} + +configure_nuitka_mode() { + local nuitka_mode + + if [ "${NUITKA_SERVICE:-false}" = "true" ]; then + nuitka_mode="1" + else + nuitka_mode="0" + fi + + if [ -f "$ENV_FILE" ] && [ -z "${NUITKA_SERVICE+x}" ]; then + nuitka_mode="$(awk -F= '/^NUITKA_BUILD=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" + if [ -z "$nuitka_mode" ]; then + nuitka_mode="0" + fi + fi + + NUITKA_BUILD_VALUE="$nuitka_mode" + + if [ "$nuitka_mode" = "1" ]; then + echo "Nuitka service mode: enabled (compiled app module)" + else + echo "Nuitka service mode: disabled (standard Python app module)" + fi +} + +resolve_app_image() { + local env_image release_tag + + if [ -f "$ENV_FILE" ]; then + env_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2}' "$ENV_FILE" | tail -n1 | tr -d ' ' || true)" + if [ -n "$env_image" ] && [ "$env_image" != "ghcr.io/aiirondev/inventarsystem:latest" ]; then + APP_IMAGE_VALUE="$env_image" + return 0 + fi + fi + + release_tag="" + if [ -f "$SCRIPT_DIR/.release-version" ]; then + release_tag="$(tr -d '[:space:]' < "$SCRIPT_DIR/.release-version")" + fi + + if [ -n "$release_tag" ]; then + APP_IMAGE_VALUE="ghcr.io/aiirondev/inventarsystem:$release_tag" + return 0 + fi + + if [ -n "$env_image" ]; then + APP_IMAGE_VALUE="$env_image" + fi +} + +port_in_use() { + local port="$1" + + if ! command -v ss >/dev/null 2>&1; then + return 1 + fi + + ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {print $4}' | grep -q . +} + +find_free_port() { + local port="$1" + while port_in_use "$port"; do + port=$((port + 1)) + done + echo "$port" +} + +stack_owns_host_port() { + local requested_port="$1" + local container_port="$2" + local mapped_port + + mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)" + if [ -z "$mapped_port" ]; then + return 1 + fi + + mapped_port="${mapped_port##*:}" + [ "$mapped_port" = "$requested_port" ] +} + +stop_host_nginx_services() { + local stopped_any=false + local service_name + + if ! command -v systemctl >/dev/null 2>&1; then + return 1 + fi + + while IFS= read -r service_name; do + [ -z "$service_name" ] && continue + echo "Stopping host service $service_name to free web ports..." + $SUDO systemctl stop "$service_name" >/dev/null 2>&1 || true + stopped_any=true + done < <(systemctl list-units --type=service --state=active --no-pager 2>/dev/null | awk '{print $1}' | grep -E '(^nginx\.service$|nginx)' || true) + + if [ "$stopped_any" = true ]; then + sleep 2 + fi + + if [ "$stopped_any" = true ]; then + return 0 + fi + + return 1 +} + +configure_host_ports() { + local requested_http requested_https + + requested_http="" + if [ -f "$ENV_FILE" ]; then + requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" + fi + if [ -z "$requested_http" ]; then + requested_http="80" + fi + + if stack_owns_host_port "$requested_http" "80"; then + HTTP_PORT_VALUE="$requested_http" + elif port_in_use "$requested_http"; then + stop_host_nginx_services || true + + if ! port_in_use "$requested_http"; then + HTTP_PORT_VALUE="$requested_http" + echo "Freed HTTP port $requested_http by stopping host nginx service" + else + HTTP_PORT_VALUE="$(find_free_port 8080)" + echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE" + fi + else + HTTP_PORT_VALUE="$requested_http" + fi + + requested_https="" + if [ -f "$ENV_FILE" ]; then + requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" + fi + + if [ -z "$requested_https" ]; then + requested_https="443" + fi + + if stack_owns_host_port "$requested_https" "443"; then + HTTPS_PORT_VALUE="$requested_https" + elif port_in_use "$requested_https"; then + stop_host_nginx_services || true + + if ! port_in_use "$requested_https"; then + HTTPS_PORT_VALUE="$requested_https" + echo "Freed HTTPS port $requested_https by stopping host nginx service" + return + fi + + HTTPS_PORT_VALUE="$(find_free_port 8443)" + echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE" + else + HTTPS_PORT_VALUE="$requested_https" + fi +} + +write_env_file() { + cat > "$ENV_FILE" </dev/null || true)" + if printf '%s\n' "$running_services" | grep -Fxq app && \ + printf '%s\n' "$running_services" | grep -Fxq nginx && \ + printf '%s\n' "$running_services" | grep -Fxq mongodb; then + if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then + if curl -kfsS "https://127.0.0.1:$HTTPS_PORT_VALUE" >/dev/null 2>&1; then + echo "Health check passed." + return 0 + fi + fi + fi + sleep 2 + done + + # First failure: attempt recovery by restarting containers + if [[ $retry_count -eq 0 ]]; then + echo "Health check failed. Attempting to restart containers..." + docker compose "${compose_args[@]}" ps || true + docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true + docker compose "${compose_args[@]}" restart app nginx mongodb + sleep 3 + ((retry_count++)) + else + break + fi + done + + # Final failure + echo "Error: stack health check failed after restart attempt." + docker compose "${compose_args[@]}" ps || true + docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true + return 1 +} + +parse_args "$@" + +ensure_runtime_dependencies +setup_boot_autostart_service +ensure_tls_certificates +ensure_nginx_config_mount_source +setup_scheduled_jobs +configure_nuitka_mode +resolve_app_image +configure_host_ports +ensure_app_image_loaded +write_env_file + +echo "Starting Inventarsystem Docker stack (app + mongodb)..." +docker compose --env-file "$ENV_FILE" up -d --remove-orphans + +verify_stack_health + +echo "Stack started." +echo "Open: https://:$HTTPS_PORT_VALUE" diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..3f5d53f --- /dev/null +++ b/stop.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker command not found. Install Docker first." + exit 1 +fi + +echo "Stopping Inventarsystem Docker stack..." +docker compose down + +echo "Stack stopped." diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..3047cde --- /dev/null +++ b/update.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Release-only updater for Docker deployment. +# Updates are pulled exclusively from GitHub Releases assets. + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_DIR="$PROJECT_DIR/logs" +LOG_FILE="$LOG_DIR/update.log" +STATE_FILE="$PROJECT_DIR/.release-version" +REPO_SLUG="AIIrondev/Inventarsystem" +API_URL="https://api.github.com/repos/$REPO_SLUG/releases/latest" +BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz" +APP_IMAGE_ASSET_PREFIX="inventarsystem-image-" +ENV_FILE="$PROJECT_DIR/.docker-build.env" +APP_IMAGE_REPO="ghcr.io/aiirondev/inventarsystem" +DIST_DIR="$PROJECT_DIR/dist" + +mkdir -p "$LOG_DIR" +chmod 777 "$LOG_DIR" 2>/dev/null || true + +log_message() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + log_message "ERROR: Required command not found: $1" + exit 1 + fi +} + +SUDO="" +if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" +fi + +apt_install() { + $SUDO apt-get update -y + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" +} + +ensure_runtime_dependencies() { + local missing=() + + if ! command -v docker >/dev/null 2>&1; then + missing+=(docker.io) + fi + + if ! docker compose version >/dev/null 2>&1; then + missing+=(docker-compose-v2) + fi + + if ! command -v openssl >/dev/null 2>&1; then + missing+=(openssl) + fi + + if ! command -v curl >/dev/null 2>&1; then + missing+=(curl) + fi + + if ! command -v python3 >/dev/null 2>&1; then + missing+=(python3) + fi + + if [ "${#missing[@]}" -gt 0 ]; then + log_message "Installing missing dependencies: ${missing[*]}" + apt_install "${missing[@]}" + fi + + if command -v systemctl >/dev/null 2>&1; then + $SUDO systemctl enable --now docker >/dev/null 2>&1 || true + fi +} + +ensure_tls_certificates() { + local cert_dir cert_path key_path cn + cert_dir="$PROJECT_DIR/certs" + cert_path="$cert_dir/inventarsystem.crt" + key_path="$cert_dir/inventarsystem.key" + + mkdir -p "$cert_dir" + + if [ -f "$cert_path" ] && [ -f "$key_path" ]; then + return 0 + fi + + cn="${TLS_CN:-localhost}" + log_message "No TLS certificates found. Generating self-signed certificate for CN=$cn" + + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$key_path" \ + -out "$cert_path" \ + -subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1 + + chmod 600 "$key_path" + chmod 644 "$cert_path" +} + +ensure_nginx_config_mount_source() { + local nginx_dir config_path backup_path + nginx_dir="$PROJECT_DIR/docker/nginx" + config_path="$nginx_dir/default.conf" + + mkdir -p "$nginx_dir" + + if [ -d "$config_path" ]; then + backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak" + mv "$config_path" "$backup_path" + log_message "WARNING: Moved unexpected directory $config_path to $backup_path" + fi + + if [ ! -f "$config_path" ]; then + cat > "$config_path" <<'EOF' +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/inventarsystem.crt; + ssl_certificate_key /etc/nginx/certs/inventarsystem.key; + + client_max_body_size 50M; + + location / { + proxy_pass http://app:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + default_type text/html; + return 200 'Server Error

Server Error

The service is temporarily unavailable.

'; + } +} +EOF + log_message "Recreated missing nginx config at $config_path" + fi +} + +archive_logs() { + log_message "Checking for monthly log archival..." + if [ -x "$PROJECT_DIR/archive-logs.sh" ]; then + if "$PROJECT_DIR/archive-logs.sh" >> "$LOG_FILE" 2>&1; then + log_message "Log archival check completed" + else + log_message "WARNING: Log archival encountered issues; continuing" + fi + fi +} + +create_backup() { + log_message "Creating database backup before update..." + if [ -x "$PROJECT_DIR/backup.sh" ]; then + if "$PROJECT_DIR/backup.sh" --mode auto >> "$LOG_FILE" 2>&1; then + log_message "Universal backup completed" + return 0 + else + log_message "WARNING: Universal backup failed; trying legacy backup path" + fi + fi + + if [ -x "$PROJECT_DIR/run-backup.sh" ]; then + if "$PROJECT_DIR/run-backup.sh" >> "$LOG_FILE" 2>&1; then + log_message "Backup completed" + else + log_message "WARNING: Backup failed; continuing with release update" + fi + else + log_message "WARNING: run-backup.sh not found; skipping backup" + fi +} + +fetch_release_metadata() { + local meta_file + meta_file="$1" + curl -fsSL "$API_URL" -o "$meta_file" +} + +parse_latest_tag() { + local meta_file + meta_file="$1" + python3 - <<'PY' "$meta_file" +import json, sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +print(data.get('tag_name', '').strip()) +PY +} + +parse_asset_url() { + local meta_file asset_name + meta_file="$1" + asset_name="$2" + python3 - <<'PY' "$meta_file" "$asset_name" +import json, sys +meta_file, asset_name = sys.argv[1], sys.argv[2] +with open(meta_file, 'r', encoding='utf-8') as f: + data = json.load(f) +for asset in data.get('assets', []): + if asset.get('name') == asset_name: + print(asset.get('browser_download_url', '').strip()) + break +PY +} + +load_release_image() { + local meta_file tag image_asset image_url tmp_dir archive + + meta_file="$1" + tag="$2" + image_asset="${APP_IMAGE_ASSET_PREFIX}${tag}.tar.gz" + image_url="$(parse_asset_url "$meta_file" "$image_asset")" + + if [ -z "$image_url" ]; then + log_message "ERROR: Release image asset not found: $image_asset" + return 1 + fi + + tmp_dir="$(mktemp -d)" + archive="$tmp_dir/$image_asset" + trap 'rm -rf "${tmp_dir:-}"' RETURN + + log_message "Loading app image from release asset $image_asset" + curl -fL "$image_url" -o "$archive" + docker load -i "$archive" >> "$LOG_FILE" 2>&1 + + trap - RETURN +} + +find_local_dist_image_archive() { + local tag="$1" + local archive + + if [ ! -d "$DIST_DIR" ]; then + return 1 + fi + + for archive in \ + "$DIST_DIR/inventarsystem-image-$tag.tar.gz" \ + "$DIST_DIR/inventarsystem-image-$tag.tar" \ + "$DIST_DIR/inventarsystem-image.tar.gz" \ + "$DIST_DIR/inventarsystem-image.tar"; do + if [ -f "$archive" ]; then + echo "$archive" + return 0 + fi + done + + archive="$(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) | sort | tail -n1)" + if [ -n "$archive" ]; then + echo "$archive" + return 0 + fi + + return 1 +} + +load_local_dist_image() { + local tag="$1" + local archive + + archive="$(find_local_dist_image_archive "$tag" || true)" + if [ -z "$archive" ]; then + return 1 + fi + + log_message "Loading app image from local dist artifact: $archive" + if docker load -i "$archive" >> "$LOG_FILE" 2>&1; then + return 0 + fi + + log_message "WARNING: Failed to load local dist artifact: $archive" + return 1 +} + +download_and_extract_bundle() { + local url tmp_dir archive + url="$1" + tmp_dir="$2" + archive="$tmp_dir/$BUNDLE_ASSET" + + curl -fL "$url" -o "$archive" + tar -xzf "$archive" -C "$tmp_dir" + + # The bundle must contain docker deployment files only. + mkdir -p "$PROJECT_DIR/docker/nginx" + cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml" + cp -f "$tmp_dir/docker/nginx/default.conf" "$PROJECT_DIR/docker/nginx/default.conf" + cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh" + cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh" + + if [ -f "$tmp_dir/restart.sh" ]; then + cp -f "$tmp_dir/restart.sh" "$PROJECT_DIR/restart.sh" + fi + + if [ -f "$tmp_dir/update.sh" ]; then + cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh" + fi + + chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" +} + +deploy() { + local tag="$1" + local meta_file="$2" + local app_image="${APP_IMAGE_REPO}:${tag}" + + cd "$PROJECT_DIR" + if [ ! -f "$ENV_FILE" ]; then + cat > "$ENV_FILE" <> "$ENV_FILE" + fi + + if ! load_local_dist_image "$tag"; then + if ! load_release_image "$meta_file" "$tag"; then + log_message "Falling back to tagged GHCR image $app_image" + if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then + log_message "Falling back to local Docker build for $app_image" + docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1 + fi + fi + fi + + docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1 + docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1 +} + +verify_stack_health() { + local compose_args running_services + local https_port + compose_args=(--env-file "$ENV_FILE") + https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')" + if [ -z "$https_port" ]; then + https_port="443" + fi + + for _ in $(seq 1 60); do + running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" + if printf '%s\n' "$running_services" | grep -Fxq app && \ + printf '%s\n' "$running_services" | grep -Fxq nginx && \ + printf '%s\n' "$running_services" | grep -Fxq mongodb; then + # Primary check: HTTP endpoint responds (most reliable) + if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then + return 0 + fi + fi + sleep 2 + done + + docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true + docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb >> "$LOG_FILE" 2>&1 || true + return 1 +} + +main() { + ensure_runtime_dependencies + ensure_tls_certificates + ensure_nginx_config_mount_source + + require_cmd curl + require_cmd tar + require_cmd docker + require_cmd python3 + + archive_logs + create_backup + + local tmp_dir meta_file latest_tag current_tag bundle_url + tmp_dir="$(mktemp -d)" + meta_file="$tmp_dir/release.json" + + trap 'rm -rf "${tmp_dir:-}"' EXIT + + log_message "Checking latest GitHub release for $REPO_SLUG..." + if ! fetch_release_metadata "$meta_file"; then + log_message "WARNING: Could not fetch release metadata. Falling back to self-healing start path." + if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then + if verify_stack_health; then + log_message "Fallback deployment completed" + else + log_message "ERROR: Fallback deployment failed health check" + exit 1 + fi + else + log_message "ERROR: Fallback start path failed" + exit 1 + fi + exit 0 + fi + + latest_tag="$(parse_latest_tag "$meta_file")" + if [ -z "$latest_tag" ]; then + log_message "WARNING: Could not determine latest release tag. Falling back to self-healing start path." + if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then + if verify_stack_health; then + log_message "Fallback deployment completed" + else + log_message "ERROR: Fallback deployment failed health check" + exit 1 + fi + else + log_message "ERROR: Fallback start path failed" + exit 1 + fi + exit 0 + fi + + current_tag="" + if [ -f "$STATE_FILE" ]; then + current_tag="$(cat "$STATE_FILE")" + fi + + if [ "$current_tag" = "$latest_tag" ]; then + log_message "Already on latest release ($latest_tag). Refreshing containers from prebuilt image." + deploy "$latest_tag" "$meta_file" + if verify_stack_health; then + log_message "Container refresh completed" + else + log_message "ERROR: Container refresh failed health check" + exit 1 + fi + exit 0 + fi + + bundle_url="$(parse_asset_url "$meta_file" "$BUNDLE_ASSET")" + if [ -z "$bundle_url" ]; then + log_message "WARNING: Release asset not found: $BUNDLE_ASSET. Falling back to self-healing start path." + if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then + if verify_stack_health; then + log_message "Image-only deployment completed" + else + log_message "ERROR: Image-only fallback failed health check" + exit 1 + fi + else + log_message "ERROR: Fallback start path failed" + exit 1 + fi + exit 0 + fi + + log_message "Updating from release $latest_tag" + download_and_extract_bundle "$bundle_url" "$tmp_dir" + deploy "$latest_tag" "$meta_file" + if ! verify_stack_health; then + log_message "ERROR: Updated stack failed health check" + exit 1 + fi + + echo "$latest_tag" > "$STATE_FILE" + log_message "Update completed successfully to release $latest_tag" +} + +main "$@" diff --git a/web.ico b/web.ico new file mode 100755 index 0000000..12786f6 Binary files /dev/null and b/web.ico differ