Initial Commit

This commit is contained in:
2026-04-10 14:48:52 +02:00
commit fad7d9a757
81 changed files with 39657 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
dist
logs
certs
build
Executable
+134
View File
@@ -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())
+177
View File
@@ -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())
+128
View File
@@ -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.
+35
View File
@@ -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", "-"]
Executable
+55
View File
@@ -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)
+17
View File
@@ -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.
+9
View File
@@ -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!
+42
View File
@@ -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 thirdparty
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.
+27
View File
@@ -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.
+12
View File
@@ -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.
Executable
+656
View File
@@ -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-<tag>.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:<tag>`
- Release-Asset `inventarsystem-docker-bundle.tar.gz` (nur Docker-Deployment-Dateien)
- Release-Asset `inventarsystem-image-<tag>.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 <ref> --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.
+24
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+7536
View File
File diff suppressed because it is too large Load Diff
+1226
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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
+103
View File
@@ -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()
Executable
+1021
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
flask
bson
werkzeug
gunicorn
pymongo
pillow
qrcode
apscheduler
python-dateutil
pytz
requests
reportlab
python-barcode
+175
View File
@@ -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)
+35
View File
@@ -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-----
+52
View File
@@ -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-----
+99
View File
@@ -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;
}
+806
View File
@@ -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;
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><rect width="200" height="200" fill="#f0f0f0"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="20" fill="#666">No Image</text></svg>

After

Width:  |  Height:  |  Size: 273 B

+400
View File
@@ -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);
}
+285
View File
@@ -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
});
}
}
+24
View File
@@ -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);
+315
View File
@@ -0,0 +1,315 @@
<!--
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
-->
{% extends 'base.html' %}
{% block content %}
<div class="container">
<h1>Ausleihen verwalten</h1>
<p>Liste aller aktiven und geplanten Ausleihen. Sie können einzelne Einträge zurücksetzen oder bei Schäden eine Rechnung als PDF erstellen.</p>
{% if entries and entries|length > 0 %}
<!-- Filter Section -->
<div style="margin-bottom: 20px; padding: 15px; background-color: #f5f5f5; border-radius: 5px;">
<h3>Filter</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
<div>
<label for="user-filter" style="display: block; margin-bottom: 5px; font-weight: bold;">Benutzer:</label>
<input
type="text"
id="user-filter"
placeholder="Nach Benutzer filtern..."
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 3px;"
/>
</div>
<div>
<label for="item-filter" style="display: block; margin-bottom: 5px; font-weight: bold;">Element:</label>
<input
type="text"
id="item-filter"
placeholder="Nach Element filtern..."
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 3px;"
/>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<label for="status-filter" style="display: block; margin-bottom: 5px; font-weight: bold;">Status:</label>
<select
id="status-filter"
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 3px;"
>
<option value="">Alle</option>
<option value="active">Aktiv</option>
<option value="planned">Geplant</option>
</select>
</div>
<div style="display: flex; align-items: flex-end;">
<button
id="reset-filters"
class="btn btn-secondary"
style="width: 100%;"
>
Filter zurücksetzen
</button>
</div>
</div>
</div>
<table class="table" style="width:100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Status</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Element</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Element-ID</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Benutzer</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Start</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Ende</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Stunde</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Preis</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Rechnung</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Notizen</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Aktion</th>
</tr>
</thead>
<tbody id="borrowing-table-body">
{% for e in entries %}
<tr class="borrowing-row"
data-user="{{ e.user }}"
data-item="{{ e.item_name }}"
data-status="{{ e.status }}"
data-borrow-id="{{ e.id }}"
data-item-id="{{ e.item_id }}"
data-item-name="{{ e.item_name }}"
data-item-code="{{ e.item_code }}"
data-item-cost="{{ e.item_cost_raw }}"
data-user-name="{{ e.user }}">
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.status }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.item_name }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ e.item_id }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.user }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.start }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.end }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.period }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.item_cost }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">
{% if e.invoice_number %}
<div style="font-weight:600;">{{ e.invoice_number }}</div>
<div style="font-size:0.85rem; color:#666;">{{ e.invoice_amount }}</div>
{% if e.invoice_paid %}
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Bezahlt</div>
{% if e.invoice_paid_at %}
<div style="font-size:0.75rem; color:#666; margin-top:4px;">am {{ e.invoice_paid_at }}</div>
{% endif %}
{% else %}
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Offen</div>
{% endif %}
{% else %}
<div style="font-size:0.9rem; color:#666;">Noch keine Rechnung</div>
{% endif %}
</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.notes }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<button type="button" class="btn btn-danger" onclick="openInvoiceModal(this)" {% if e.status != 'active' %}disabled{% endif %}>Rechnung erstellen</button>
{% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %}
<form method="post" action="{{ url_for('admin_finalize_invoice_and_repair', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt und Element als repariert markieren?');">
<button type="submit" class="btn btn-success">
{% if not e.invoice_paid and e.has_damage %}
Bezahlt + repariert
{% elif not e.invoice_paid %}
Als bezahlt markieren
{% else %}
Als repariert markieren
{% endif %}
</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning">Zurücksetzen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="no-results" style="display: none; text-align: center; padding: 20px; color: #666;">
<p>Keine Einträge mit den aktuellen Filtern gefunden.</p>
</div>
{% else %}
<div class="empty-state">
<p>Keine aktiven oder geplanten Ausleihen gefunden.</p>
</div>
{% endif %}
</div>
<div id="invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div>
<h2 style="margin:0;">Rechnung erstellen</h2>
<p style="margin:6px 0 0; color:#666;">Preis wird aus den Anschaffungskosten vorbelegt und kann bei Bedarf angepasst werden.</p>
</div>
<button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Schließen</button>
</div>
<form id="invoice-form" method="post" action="">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div>
<label for="invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
<input id="invoice-amount" name="invoice_amount" type="text" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="z.B. 12,50">
</div>
</div>
<div style="margin-bottom:16px;">
<label for="damage-reason" style="display:block; font-weight:700; margin-bottom:6px;">Schadensbeschreibung</label>
<textarea id="damage-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div>
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="mark_destroyed" checked>
Element als zerstört markieren
</label>
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="close_borrowing" checked>
Ausleihe abschließen
</label>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const userFilter = document.getElementById('user-filter');
const itemFilter = document.getElementById('item-filter');
const statusFilter = document.getElementById('status-filter');
const resetButton = document.getElementById('reset-filters');
const tableBody = document.getElementById('borrowing-table-body');
const noResults = document.getElementById('no-results');
function applyFilters() {
const userValue = userFilter.value.toLowerCase();
const itemValue = itemFilter.value.toLowerCase();
const statusValue = statusFilter.value;
let visibleCount = 0;
document.querySelectorAll('.borrowing-row').forEach(row => {
const user = row.getAttribute('data-user').toLowerCase();
const item = row.getAttribute('data-item').toLowerCase();
const status = row.getAttribute('data-status');
const userMatch = user.includes(userValue);
const itemMatch = item.includes(itemValue);
const statusMatch = statusValue === '' || status === statusValue;
const shouldShow = userMatch && itemMatch && statusMatch;
if (shouldShow) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0) {
noResults.style.display = 'block';
} else {
noResults.style.display = 'none';
}
}
function resetFilters() {
userFilter.value = '';
itemFilter.value = '';
statusFilter.value = '';
applyFilters();
}
function normalizeMoneyInput(value) {
if (!value) {
return '';
}
return String(value).replace(' EUR', '').trim();
}
window.openInvoiceModal = function(button) {
const row = button.closest('.borrowing-row');
if (!row) {
return;
}
const borrowId = row.getAttribute('data-borrow-id') || '';
const itemName = row.getAttribute('data-item-name') || '';
const borrower = row.getAttribute('data-user-name') || '';
const itemCode = row.getAttribute('data-item-code') || '';
const itemCost = row.getAttribute('data-item-cost') || '';
const modal = document.getElementById('invoice-modal');
const form = document.getElementById('invoice-form');
const amountField = document.getElementById('invoice-amount');
const reasonField = document.getElementById('damage-reason');
form.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
document.getElementById('invoice-item').value = itemName;
document.getElementById('invoice-borrower').value = borrower;
document.getElementById('invoice-code').value = itemCode;
amountField.value = normalizeMoneyInput(itemCost);
reasonField.value = 'Das Element ' + itemName + ' wurde während der Ausleihe beschädigt oder zerstört.';
modal.style.display = 'block';
amountField.focus();
};
window.closeInvoiceModal = function() {
const modal = document.getElementById('invoice-modal');
if (modal) {
modal.style.display = 'none';
}
};
const modal = document.getElementById('invoice-modal');
if (modal) {
modal.addEventListener('click', function(event) {
if (event.target === modal) {
closeInvoiceModal();
}
});
}
// Add event listeners
userFilter.addEventListener('input', applyFilters);
itemFilter.addEventListener('input', applyFilters);
statusFilter.addEventListener('change', applyFilters);
resetButton.addEventListener('click', resetFilters);
});
</script>
{% endblock %}
+915
View File
@@ -0,0 +1,915 @@
<!--
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
-->
<!DOCTYPE html>
<html lang="en" data-module="inventory">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>{% block title %}Inventarsystem{% endblock %}</title>
{% block head %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css') }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
/* ===== MODULE DETECTION & SETUP ===== */
:root {
--module-primary-color: #2c3e50;
--module-primary-light: #34495e;
--module-accent-color: #3498db;
--module-accent-light: rgba(52, 152, 219, 0.1);
--module-brand-icon: "📦";
}
html[data-module="library"] {
--module-primary-color: #6c4e71;
--module-primary-light: #7d5a8a;
--module-accent-color: #9b59b6;
--module-accent-light: rgba(155, 89, 182, 0.1);
--module-brand-icon: "📚";
}
/* Global styles */
body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
background:
radial-gradient(1200px 500px at 10% -10%, rgba(86, 145, 200, 0.08), transparent 55%),
radial-gradient(900px 420px at 95% 0%, rgba(52, 98, 150, 0.09), transparent 52%),
linear-gradient(180deg, #f2f5f8, #e7edf4);
margin: 0;
padding: 0;
padding-bottom: 30px;
color: #1f2d3d;
}
.container {
padding-top: 20px;
}
/* Navigation styles */
.navbar {
box-shadow: 0 4px 14px rgba(2, 6, 23, 0.24);
background-color: var(--module-primary-color) !important;
}
.navbar-brand {
font-weight: bold;
font-size: 1.4rem;
}
.navbar-brand::before {
content: attr(data-icon);
margin-right: 8px;
}
.navbar-nav .nav-link {
font-size: 1rem;
font-weight: 600;
padding: 0.6rem 0.85rem;
border-radius: 8px;
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link:focus {
background-color: rgba(255, 255, 255, 0.12);
}
.nav-priority-link {
background: var(--module-accent-light);
border: 1px solid var(--module-accent-color);
}
.admin-nav-section {
margin-left: auto;
display: flex;
gap: 15px;
}
.admin-nav-section .nav-item {
background-color: var(--module-accent-color);
color: white;
padding: 5px 10px;
border-radius: 4px;
text-decoration: none;
transition: all 0.2s ease;
}
.admin-nav-section .nav-item:hover {
opacity: 0.8;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Admin dropdown styling */
.nav-item.dropdown .nav-link.dropdown-toggle {
color: #ffffff;
font-weight: 500;
position: relative;
}
.nav-item.dropdown .nav-link.dropdown-toggle:hover {
color: #ffffff;
}
/* Consistent spacing for admin nav items */
.navbar-nav .nav-item {
margin-right: 5px;
}
.navbar-nav {
align-items: center;
}
.navbar-nav .nav-item:last-child {
margin-right: 0;
}
/* Flash messages */
.flashes {
margin-top: 20px;
margin-bottom: 20px;
}
.flash {
padding: 12px 20px;
margin-bottom: 15px;
border-radius: 5px;
font-weight: 500;
}
.flash.success {
background-color: #d4edda;
color: #155724;
border-left: 5px solid #28a745;
}
.flash.error {
background-color: #f8d7da;
color: #721c24;
border-left: 5px solid #dc3545;
}
.flash.info {
background-color: #e2e3e5;
color: #383d41;
border-left: 5px solid #6c757d;
}
/* Dropdown menus - consistent with system design */
.dropdown-menu {
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 8px 0;
min-width: 200px;
margin-top: 5px;
}
.dropdown-item {
padding: 10px 16px;
font-size: 0.9rem;
color: #495057;
transition: all 0.2s ease;
border: none;
background: none;
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: #f8f9fa;
color: #007bff;
transform: translateX(2px);
}
/* Navigation dropdown specific styles */
.nav-item.dropdown .dropdown-toggle {
position: relative;
transition: all 0.2s ease;
}
.nav-item.dropdown .dropdown-toggle:hover {
background-color: rgba(255,255,255,0.1);
border-radius: 4px;
}
.nav-item.dropdown .dropdown-toggle::after {
transition: transform 0.2s ease;
}
.nav-item.dropdown.show .dropdown-toggle::after {
transform: rotate(180deg);
}
/* User account dropdown specific styles */
.dropdown-menu-end {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.dropdown-divider {
margin: 6px 0;
border-color: #dee2e6;
}
/* Version display */
.text-info {
opacity: 0.8;
}
/* Mobile responsive navigation styles */
@media screen and (max-width: 768px) {
.navbar-nav .dropdown-menu {
border: none;
box-shadow: none;
background-color: rgba(52, 58, 64, 0.95);
margin-left: 15px;
margin-top: 5px;
border-radius: 6px;
}
.navbar-nav .dropdown-item {
color: #ffffff;
padding: 12px 20px;
font-size: 0.9rem;
}
.navbar-nav .dropdown-item:hover,
.navbar-nav .dropdown-item:focus {
background-color: rgba(255,255,255,0.1);
color: #ffffff;
transform: none;
}
.nav-item.dropdown .dropdown-toggle {
padding: 8px 12px;
}
/* Better touch targets for mobile */
.dropdown-toggle::after {
margin-left: 8px;
}
/* Account dropdown stays light theme on mobile */
.dropdown-menu-end {
background-color: #ffffff;
border: 1px solid #dee2e6;
}
.dropdown-menu-end .dropdown-item {
color: #495057;
}
.dropdown-menu-end .dropdown-item:hover {
background-color: #f8f9fa;
color: #007bff;
}
}
/* Module Selector Bar Styles */
.module-selector-bar {
background: linear-gradient(90deg, var(--module-primary-color) 0%, var(--module-primary-light) 100%);
border-bottom: 3px solid var(--module-accent-color);
padding: 10px 16px;
display: flex;
justify-content: flex-start;
gap: 12px;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.module-selector-bar .module-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.7);
font-weight: 800;
margin-right: 8px;
}
.module-selector-bar .module-tabs {
display: flex;
gap: 8px;
align-items: center;
flex: 1;
}
.module-selector-bar .module-tab {
padding: 7px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1.5px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
display: inline-block;
}
.module-selector-bar .module-tab:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
transform: translateY(-2px);
}
.module-selector-bar .module-tab.active {
background: var(--module-accent-color);
border-color: rgba(255, 255, 255, 0.8);
color: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
.module-separator {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.25);
margin: 0 4px;
}
@media (max-width: 768px) {
.module-selector-bar {
padding: 8px 12px;
gap: 8px;
}
.module-selector-bar .module-label {
display: none;
}
.module-selector-bar .module-tab {
padding: 5px 12px;
font-size: 0.9rem;
}
.module-separator {
height: 18px;
}
}
</style>
{% endblock %}
</head>
<body>
<!-- Module Selector Bar -->
{% if 'username' in session %}
<div class="module-selector-bar" id="moduleBar">
<span class="module-label">Module:</span>
<div class="module-tabs">
<a href="{{ url_for('home') }}"
class="module-tab"
id="inventoryModule"
data-module="inventory">
📦 Inventarsystem
</a>
{% if library_module_enabled %}
<div class="module-separator"></div>
<a href="{{ url_for('library_view') }}"
class="module-tab"
id="libraryModule"
data-module="library">
📚 Bibliothek
</a>
{% endif %}
</div>
</div>
{% endif %}
<script>
(function() {
// Detect current module based on URL and update data-module attribute
const currentPath = window.location.pathname;
const htmlTag = document.documentElement;
// Detect module and pages
const isLibraryModule = currentPath.includes('/library') ||
currentPath.includes('/library_admin') ||
currentPath.includes('/library_loans') ||
currentPath.includes('/student_cards');
const currentModule = isLibraryModule ? 'library' : 'inventory';
htmlTag.setAttribute('data-module', currentModule);
// Update module selector tabs
const invModule = document.getElementById('inventoryModule');
const libModule = document.getElementById('libraryModule');
if (currentModule === 'library') {
if (libModule) libModule.classList.add('active');
if (invModule) invModule.classList.remove('active');
} else {
if (invModule) invModule.classList.add('active');
if (libModule) libModule.classList.remove('active');
}
// Show/hide appropriate navbar based on current module
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentModule === 'library') {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#inventoryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="inventoryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link" href="{{ url_for('home') }}">Artikel</a>
</li>
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li class="nav-item">
<a class="nav-link nav-priority-link" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li>
{% endif %}
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
⋯ Mehr
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</li>
<li class="nav-item d-none" id="inv-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2">
<button class="btn btn-secondary dropdown-toggle" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
👤
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% if library_module_enabled %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar" style="display: none;">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('library_view') }}" data-icon="📚">Bibliothek</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="libraryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link" href="{{ url_for('library_view') }}">Medien</a>
</li>
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li class="nav-item">
<a class="nav-link nav-priority-link" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li>
{% endif %}
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
⋯ Mehr
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Schülerausweise</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</li>
<li class="nav-item d-none" id="lib-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2">
<button class="btn btn-secondary dropdown-toggle" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
👤
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% endif %}
<script>
(function() {
// Show/hide appropriate navbar based on current module
const currentPath = window.location.pathname;
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentPath.includes('/library')) {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<!-- Cookie consent banner -->
<style>
#cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); }
#cookie-banner .cb-inner { max-width: 1100px; margin: 0 auto; display:flex; gap:12px; align-items:center; justify-content:space-between; }
#cookie-banner .cb-text { font-size: .95rem; line-height:1.35; margin-right: 12px; }
#cookie-banner .cb-actions { display:flex; gap:8px; flex-shrink:0; }
#cookie-banner .btn-accept { background:#28a745; border:2px solid #28a745; color:#fff; padding:6px 12px; border-radius:4px; cursor:pointer; }
#cookie-banner .btn-decline { background:transparent; border:2px solid #ffc107; color:#ffc107; padding:6px 12px; border-radius:4px; cursor:pointer; }
#cookie-banner .btn-accept:hover { background:#218838; border-color:#218838; }
#cookie-banner .btn-decline:hover { background:#ffc107; color:#212529; }
#onboarding-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 2100;
padding: 16px;
}
#onboarding-modal {
width: min(680px, 100%);
background: #ffffff;
border-radius: 14px;
border: 1px solid #dbe3ee;
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.25);
padding: 20px;
}
#onboarding-modal h3 {
margin: 0 0 8px;
color: #0f172a;
font-size: 1.2rem;
font-weight: 800;
}
#onboarding-modal p {
margin: 0;
color: #334155;
line-height: 1.45;
}
.onboarding-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.onboarding-actions .btn {
border-radius: 10px;
}
.dynamic-overflow-control .dropdown-menu {
min-width: 240px;
max-width: min(420px, 85vw);
max-height: 70vh;
overflow-y: auto;
}
.dynamic-overflow-control .dropdown-header {
font-weight: 800;
color: var(--module-accent-color);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
<div class="cb-inner">
<div class="cb-text">
Wir verwenden technisch notwendige Cookies, um Ihre Sitzung zu verwalten und die Anwendung bereitzustellen. Bitte akzeptieren Sie Cookies, um fortzufahren.
</div>
<div class="cb-actions">
<button class="btn-decline" id="cookie-decline">Ablehnen</button>
<button class="btn-accept" id="cookie-accept">Akzeptieren</button>
</div>
</div>
</div>
<div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag">
<div id="onboarding-modal">
<h3>Wollen Sie eine Vorstellung des Produkts?</h3>
<p>
Wir empfehlen ein kurzes Tutorial (2-5 Minuten), damit Sie die Kernfunktionen anhand einer vorgefertigten Seite kennenlernen und gut auf den Schulalltagsbetrieb vorbereitet sind.
</p>
<div class="onboarding-actions">
<button type="button" class="btn btn-primary btn-sm" id="onboarding-start">Ja, Tutorial starten</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="onboarding-later">Später erinnern</button>
<button type="button" class="btn btn-outline-dark btn-sm" id="onboarding-dismiss">Nicht mehr anzeigen</button>
</div>
</div>
</div>
<script>
(function(){
function getCookie(name){
const v = document.cookie.split(';').map(s=>s.trim());
for(const c of v){ if(c.startsWith(name+'=')) return decodeURIComponent(c.split('=')[1]); }
return null;
}
function setCookie(name, value, days){
const d = new Date(); d.setTime(d.getTime() + (days*24*60*60*1000));
document.cookie = name + '=' + encodeURIComponent(value) + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
}
function showBanner(){ var el = document.getElementById('cookie-banner'); if(el) el.style.display = 'block'; }
function hideBanner(){ var el = document.getElementById('cookie-banner'); if(el) el.style.display = 'none'; }
// If not decided yet, show banner and block app until decision
const consent = getCookie('cookie_consent');
if(!consent){
showBanner();
// Optionally blur content until consent
document.body.style.filter = 'none';
}
document.getElementById('cookie-accept')?.addEventListener('click', function(){
setCookie('cookie_consent','accepted',365);
hideBanner();
});
document.getElementById('cookie-decline')?.addEventListener('click', function(){
setCookie('cookie_consent','declined',365);
window.location.href = 'https://www.ecosia.org/';
});
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay');
function showOnboarding(){
if (onboardingOverlay) {
onboardingOverlay.style.display = 'flex';
}
}
function hideOnboarding(){
if (onboardingOverlay) {
onboardingOverlay.style.display = 'none';
}
}
if (onboardingKey && !isTutorialPage && !isLoginPage) {
const decision = localStorage.getItem(onboardingKey);
if (!decision || decision === 'later') {
showOnboarding();
}
}
document.getElementById('onboarding-start')?.addEventListener('click', function(){
if (onboardingKey) {
localStorage.setItem(onboardingKey, 'started');
}
window.location.href = {{ url_for('tutorial_page')|tojson }};
});
document.getElementById('onboarding-later')?.addEventListener('click', function(){
if (onboardingKey) {
localStorage.setItem(onboardingKey, 'later');
}
hideOnboarding();
});
document.getElementById('onboarding-dismiss')?.addEventListener('click', function(){
if (onboardingKey) {
localStorage.setItem(onboardingKey, 'dismissed');
}
hideOnboarding();
});
const navList = document.getElementById('inventoryNavList');
const navOverflowAnchor = document.getElementById('inv-nav-overflow-anchor');
const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) {
if (!navList || !navOverflowAnchor) return;
function collectTopLevelNavSources() {
if (!navList) return [];
return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) return false;
if (!li.classList.contains('nav-item')) return false;
if (li.id === navOverflowAnchor.id) return false;
if (li.dataset.overflowControl === 'true') return false;
if (li.dataset.navFixed === 'true') return false;
return li.style.display !== 'none';
});
}
function clearOverflowControls() {
if (!navList) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
node.remove();
});
}
function restoreAllNavItems() {
if (!navList) return;
navList.querySelectorAll('li.nav-item').forEach(function(li){
if (li.id === navOverflowAnchor.id) return;
if (li.dataset.overflowControl === 'true') return;
li.style.display = '';
});
clearOverflowControls();
}
function createOverflowControl() {
const li = document.createElement('li');
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="overflowMenuToggle" role="button" data-bs-toggle="dropdown" aria-expanded="false">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="overflowMenuToggle" id="overflowMenu"></ul>';
return li;
}
function appendSourceToOverflowMenu(sourceItem, menu) {
const topLink = sourceItem.querySelector(':scope > a.nav-link');
if (!topLink) return;
const childMenu = sourceItem.querySelector(':scope > ul.dropdown-menu');
const sourceLabel = topLink.textContent.trim();
if (!childMenu) {
const entry = document.createElement('li');
entry.innerHTML = '<a class="dropdown-item" href="' + topLink.getAttribute('href') + '">' + sourceLabel + '</a>';
menu.appendChild(entry);
return;
}
const header = document.createElement('li');
header.innerHTML = '<h6 class="dropdown-header">' + sourceLabel + '</h6>';
menu.appendChild(header);
childMenu.querySelectorAll(':scope > li > a.dropdown-item').forEach(function(subLink){
const subEntry = document.createElement('li');
subEntry.innerHTML = '<a class="dropdown-item" style="padding-left: 2.5rem;" href="' + subLink.getAttribute('href') + '">' + subLink.textContent.trim() + '</a>';
menu.appendChild(subEntry);
});
const divider = document.createElement('li');
divider.innerHTML = '<hr class="dropdown-divider">';
menu.appendChild(divider);
}
function rebuildOverflowControl(hiddenSources) {
clearOverflowControls();
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu');
hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu);
});
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
if (trailingDivider) {
trailingDivider.parentElement.remove();
}
navList.insertBefore(control, navOverflowAnchor);
}
function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return;
if (window.innerWidth < 992) {
restoreAllNavItems();
return;
}
restoreAllNavItems();
const hiddenSources = [];
let candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1];
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources();
}
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1];
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
}
}
let resizeTimer = null;
function debounceAdapt() {
if (resizeTimer) {
window.clearTimeout(resizeTimer);
}
resizeTimer = window.setTimeout(adaptNavbarByWidth, 120);
}
adaptNavbarByWidth();
window.addEventListener('resize', debounceAdapt);
}
// Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor);
}
// Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
}
})();
</script>
<!-- Mobile compatibility scripts -->
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
</body>
</html>
+144
View File
@@ -0,0 +1,144 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Passwort ändern - Inventarsystem{% endblock %}
{% block content %}
<div class="container">
<div class="password-change-form">
<h2>Passwort ändern</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('change_password') }}">
<div class="form-group">
<label for="current_password">Aktuelles Passwort:</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div class="form-group">
<label for="new_password">Neues Passwort:</label>
<input type="password" id="new_password" name="new_password" required>
<small class="form-text text-muted">Das Passwort sollte mindestens 6 Zeichen lang sein.</small>
</div>
<div class="form-group">
<label for="confirm_password">Neues Passwort bestätigen:</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Passwort ändern</button>
<a href="{{ url_for('home') }}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
<style>
.password-change-form {
max-width: 500px;
margin: 50px auto;
padding: 30px;
background-color: #f8f9fa;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
margin-bottom: 30px;
color: #343a40;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
}
.form-text {
display: block;
margin-top: 5px;
font-size: 14px;
}
.form-actions {
display: flex;
justify-content: space-between;
margin-top: 30px;
}
.btn {
padding: 10px 20px;
font-size: 16px;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #007bff;
color: white;
border: none;
}
.btn-primary:hover {
background-color: #0069d9;
}
.btn-secondary {
background-color: #6c757d;
color: white;
border: none;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.alert {
padding: 12px;
margin-bottom: 20px;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
</style>
{% endblock %}
+288
View File
@@ -0,0 +1,288 @@
<!--
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
-->
<!-- Edit Item Functions -->
<script>
// Function to check if a file is a video
function isVideoFile(filename) {
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return videoExtensions.includes(extension);
}
// Load location options for edit modal
function loadLocationOptions() {
fetch('/get_predefined_locations')
.then(response => response.json())
.then(data => {
const ortSelect = document.getElementById('edit-location');
if (ortSelect) {
// Clear existing options except the first one
while (ortSelect.children.length > 1) {
ortSelect.removeChild(ortSelect.lastChild);
}
// Add new options - data.locations contains the array
data.locations.forEach(location => {
const option = document.createElement('option');
option.value = location;
option.textContent = location;
ortSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error loading location options:', error);
});
}
// Edit modal functions
function openEditModal(itemId) {
if (typeof openEditModalFromServer === 'function') {
openEditModalFromServer(itemId);
return;
}
// Find the item data from allItems array
const item = allItems.find(i => i._id === itemId);
if (!item) {
console.error('Item not found:', itemId);
return;
}
// Populate the edit form with current item data
document.getElementById('edit-item-id').value = item._id;
document.getElementById('edit-name').value = item.Name || '';
document.getElementById('edit-description').value = item.Beschreibung || '';
document.getElementById('edit-code4').value = item.Code_4 || '';
document.getElementById('edit-year').value = item.Anschaffungsjahr || '';
document.getElementById('edit-cost').value = item.Anschaffungskosten || '';
// Set reservable status (default to true if undefined)
const reservierbarCheckbox = document.getElementById('edit-reservierbar');
if (reservierbarCheckbox) {
reservierbarCheckbox.checked = item.Reservierbar !== false;
}
// Load location options
loadLocationOptions();
// Set the current location
setTimeout(() => {
const locationSelect = document.getElementById('edit-location');
if (locationSelect && item.Ort) {
locationSelect.value = item.Ort;
}
}, 100);
// Handle filter arrays - set current values
const filter1Array = Array.isArray(item.Filter) ? item.Filter : (item.Filter ? [item.Filter] : []);
const filter2Array = Array.isArray(item.Filter2) ? item.Filter2 : (item.Filter2 ? [item.Filter2] : []);
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
// Set filter dropdowns (up to 4 each)
for (let i = 1; i <= 4; i++) {
// Filter 1
const filter1Select = document.getElementById(`edit-filter1-${i}`);
if (filter1Select) {
filter1Select.value = filter1Array[i-1] || '';
}
// Filter 2
const filter2Select = document.getElementById(`edit-filter2-${i}`);
if (filter2Select) {
filter2Select.value = filter2Array[i-1] || '';
}
// Filter 3
const filter3Select = document.getElementById(`edit-filter3-${i}`);
if (filter3Select) {
filter3Select.value = filter3Array[i-1] || '';
}
}
// Populate existing images
populateExistingImages(item.Images || []);
// Show the modal
document.getElementById('edit-modal').style.display = 'block';
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
// Function to add new location (for edit modal)
function addNewLocation(prefix) {
// Use different input IDs based on whether we're in edit mode
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
const selectId = prefix === 'edit' ? 'edit-location' : 'ort';
const newLocationInput = document.getElementById(inputId);
const newLocation = newLocationInput.value.trim();
if (!newLocation) {
alert('Bitte geben Sie einen Ort ein.');
return;
}
// Add to dropdown
const ortSelect = document.getElementById(selectId);
const option = document.createElement('option');
option.value = newLocation;
option.textContent = newLocation;
ortSelect.appendChild(option);
ortSelect.value = newLocation;
// Hide the input field
document.getElementById(prefix + '-new-location-container').style.display = 'none';
newLocationInput.value = '';
// Save to server
fetch('/add_location_value', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'value=' + encodeURIComponent(newLocation)
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.warn('Failed to save location to server:', data.error);
}
})
.catch(error => {
console.error('Error saving location:', error);
});
}
// Function to cancel adding a new location
function cancelAddLocation(prefix) {
const containerId = prefix === 'edit' ? 'edit-new-location-container' : 'new-location-container';
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
document.getElementById(containerId).style.display = 'none';
document.getElementById(inputId).value = '';
}
// Function to populate existing images in edit modal
function populateExistingImages(images) {
const previewContainer = document.getElementById('edit-image-preview-container');
if (!previewContainer) return;
previewContainer.innerHTML = '';
if (!images || images.length === 0) {
previewContainer.innerHTML = '<p>Keine Bilder vorhanden</p>';
return;
}
images.forEach((imageName, index) => {
const preview = document.createElement('div');
preview.className = 'image-preview-item';
const isVideo = isVideoFile(imageName);
const mediaHtml = isVideo
? `<video src="/uploads/${imageName}" style="max-width: 150px; max-height: 150px; object-fit: cover;" controls preload="metadata"></video>`
: `<img src="/uploads/${imageName}" alt="Image ${index + 1}" style="max-width: 150px; max-height: 150px; object-fit: cover;">`;
preview.innerHTML = `
${mediaHtml}
<div class="image-controls">
<button type="button" onclick="removeExistingImage('${imageName}', this)" style="background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; margin-left: 10px;">Entfernen</button>
</div>
`;
previewContainer.appendChild(preview);
// Add hidden input for the image
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = imageName;
previewContainer.appendChild(hiddenInput);
});
}
// Function to remove an existing image
function removeExistingImage(imageName, button) {
try {
// First, determine context - are we in edit mode or main view?
const inEditMode = !!document.getElementById('edit-item-form');
// Always remove the preview element (works in both contexts)
const previewItem = button.closest('.image-preview-item');
if (previewItem) {
previewItem.remove();
}
// If we're in edit mode, handle form inputs
if (inEditMode) {
// Find and remove the corresponding hidden input in the edit form
const editForm = document.getElementById('edit-item-form');
if (editForm) {
// Remove from existing images
const existingInputs = editForm.querySelectorAll('input[name="existing_images"]');
existingInputs.forEach(input => {
if (input.value === imageName) {
input.remove();
}
});
// Add to removed images
const removedInput = document.createElement('input');
removedInput.type = 'hidden';
removedInput.name = 'removed_images';
removedInput.value = imageName;
editForm.appendChild(removedInput);
}
} else {
// In main view, we may need different logic
console.log(`Image ${imageName} removed from display in main view`);
// Add any main view specific handling here
}
} catch (error) {
console.error(`Error in removeExistingImage: ${error.message}`);
}
}
// Validate file types for image uploads
document.addEventListener('DOMContentLoaded', function() {
// Check if we're in the edit item context
if (!document.getElementById('edit-item-form')) {
console.log('Edit item form not found, skipping edit item functions initialization');
return; // Exit early if we're not in the edit item context
}
const imageInput = document.getElementById('edit-new-images');
const previewContainer = document.getElementById('edit-image-preview-container');
if (imageInput) {
imageInput.addEventListener('change', function(e) {
// Validate file types before preview
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska',
'video/webm', 'video/x-flv', 'video/mp4', 'video/3gpp'];
const files = this.files;
let hasInvalidFile = false;
for (let i = 0; i < files.length; i++) {
if (!allowedTypes.includes(files[i].type)) {
hasInvalidFile = true;
// Clear the file input to prevent submission
this.value = '';
alert('Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP');
return; // Stop processing
}
}
// Continue with regular preview handling...
});
}
});
</script>
+60
View File
@@ -0,0 +1,60 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Impressum - Inventarsystem{% endblock %}
{% block content %}
<div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded">
<h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 TMG)</p>
<div class="impressum-section mb-4">
<h3 class="mb-3">Kontaktinformationen</h3>
<p><strong>Name:</strong> . ..</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
<p><strong>E-Mail:</strong> <a href="mailto:kontakt@example.com">kontakt@example.com</a></p>
<p><strong>Telefon:</strong> +49 123 456789</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 55 Abs. 2 RStV)</p>
<p>...<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Deutschland</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Haftungsausschluss</h3>
<h4 class="mb-2">Haftung für Inhalte</h4>
<p>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.</p>
<h4 class="mb-2">Haftung für Links</h4>
<p>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.</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Urheberrecht</h3>
<p>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.</p>
<p>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.</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Datenschutz</h3>
<p>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.</p>
<p>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.</p>
<p>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.</p>
</div>
</div>
</div>
{% endblock %}
+488
View File
@@ -0,0 +1,488 @@
{% extends "base.html" %}
{% block title %}Bibliothek - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="content-container">
<!-- Header Section -->
<div class="header-section">
<div class="header-title-group">
<h1>📚 Bibliothek</h1>
<p class="subtitle">Bücher, CDs und weitere Medien</p>
</div>
<div class="header-controls">
<!-- Favorites Toggle -->
<div class="view-switch">
<button id="favoriteToggle" class="toggle-btn" aria-label="Favoriten anzeigen/verbergen">
<span class="toggle-icon"></span>
<span class="toggle-label">Favoriten</span>
</button>
</div>
<!-- View Mode Toggle -->
<div class="view-switch">
<button id="viewModeToggle" class="toggle-btn" aria-label="Ansichtsmodus wechseln">
<span class="toggle-icon"></span>
<span class="toggle-label">Tabelle</span>
</button>
</div>
</div>
</div>
<!-- Search & Filter Section -->
<div class="search-filter-section">
<input type="text" id="searchInput" placeholder="Nach Titel, Autor, ISBN suchen..." class="search-input">
<div class="filter-group" id="filterGroup">
<button class="filter-btn" id="filterBtn" aria-expanded="false" aria-label="Filter öffnen">
🔍 Filter <span class="filter-arrow"></span>
</button>
<div class="filter-dropdown" id="filterDropdown" style="display: none;">
<div class="filter-section">
<h4>Medientyp</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="book"> Buch</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="cd"> CD/DVD</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="other"> Sonstige</label>
</div>
</div>
<div class="filter-section">
<h4>Status</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="available"> Verfügbar</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="borrowed"> Ausgeliehen</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="reserved"> Reserviert</label>
</div>
</div>
<button id="clearFiltersBtn" class="button">Filter zurücksetzen</button>
</div>
</div>
<!-- Barcode Scanner -->
<button id="scannerBtn" class="scan-button" aria-expanded="false" aria-label="Scanner öffnen">
📱 Scanner <span class="scanner-arrow"></span>
</button>
</div>
<!-- Scanner Panel -->
<div id="qrContainer" class="qr-container" style="display: none;">
<div id="qr-reader" style="width: auto; height: 300px;"></div>
<span id="qr-result" style="display: none;"></span>
</div>
<!-- Table Header (only in table mode) -->
<div class="table-header" id="tableHeader" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">Titel</div>
<div class="table-cell author-cell">Autor/Künstler</div>
<div class="table-cell isbn-cell">ISBN/Code</div>
<div class="table-cell type-cell">Typ</div>
<div class="table-cell status-cell">Status</div>
<div class="table-cell actions-cell">Aktionen</div>
</div>
</div>
<!-- Items Container -->
<div id="itemsContainer" class="items-container">
{% if library_items %}
{% for item in library_items %}
<div class="item-card library-item" data-item-id="{{ item._id }}" data-type="{{ item.get('ItemType', 'book') }}" data-status="{{ 'borrowed' if item.get('Verfuegbar') == False else 'available' }}">
<!-- Card Mode View -->
<div class="item-content card-mode">
<div class="item-image-wrapper">
{% if item.get('Image') %}
<img src="/uploads/{{ item.Image }}" alt="{{ item.Name }}" class="item-image">
{% else %}
<div class="placeholder-image">📚</div>
{% endif %}
</div>
<div class="item-details">
<h3 class="item-name">{{ item.Name }}</h3>
{% if item.get('Author') %}
<p class="item-author">Von: {{ item.Author }}</p>
{% endif %}
{% if item.get('ISBN') %}
<p class="item-isbn">ISBN: {{ item.ISBN }}</p>
{% endif %}
<div class="item-meta">
<span class="item-type">{{ item.get('ItemType', 'Medium') }}</span>
<span class="item-status {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
</div>
</div>
<!-- Table Mode View -->
<div class="item-content table-mode" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">
<span class="descriptor">Titel:</span>
<span class="value">{{ item.Name }}</span>
</div>
<div class="table-cell author-cell">
<span class="descriptor">Autor:</span>
<span class="value">{{ item.get('Author', '—') }}</span>
</div>
<div class="table-cell isbn-cell">
<span class="descriptor">ISBN:</span>
<span class="value">{{ item.get('ISBN', '—') }}</span>
</div>
<div class="table-cell type-cell">
<span class="descriptor">Typ:</span>
<span class="value">{{ item.get('ItemType', 'Medium') }}</span>
</div>
<div class="table-cell status-cell">
<span class="descriptor">Status:</span>
<span class="value {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
<div class="table-cell actions-cell">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
</div>
</div>
</div>
<!-- Action Buttons (Card Mode) -->
<div class="item-actions card-mode" style="display: flex;">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
{% if item.get('Verfuegbar') == True %}
<button class="action-button borrow-btn" data-item-id="{{ item._id }}">Ausleihen</button>
{% else %}
<button class="action-button return-btn" data-item-id="{{ item._id }}" disabled>Ausgeliehen</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Bibliotheks-Medien gefunden</p>
</div>
{% endif %}
</div>
</div>
<!-- Detail Modal -->
<div id="detailModal" class="modal" style="display: none;">
<div class="modal-content">
<button class="modal-close" aria-label="Schließen"></button>
<div class="modal-body" id="modalBody">
<!-- Filled by JavaScript -->
</div>
</div>
</div>
<!-- Borrow Modal -->
<div id="borrowModal" class="modal" style="display: none;">
<div class="modal-content">
<h2>Ausleihen</h2>
<form id="borrowForm">
<input type="hidden" id="borrowItemId" name="item_id">
<div class="form-group">
<label for="studentIdInput">Schülerausweis-ID (optional):</label>
<input type="text" id="studentIdInput" name="student_id" placeholder="Ausweis scannen oder eingeben" class="form-input">
</div>
<div class="form-group">
<label for="borrowDaysInput">Ausleih-Dauer (Tage):</label>
<input type="number" id="borrowDaysInput" name="borrow_days" min="1" max="365" class="form-input" value="14">
</div>
<div class="form-actions">
<button type="submit" class="button primary-button">Ausleihen</button>
<button type="button" class="button cancel-button" onclick="document.getElementById('borrowModal').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const items = container.querySelectorAll('.item-card');
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
const cardActions = item.querySelector('.item-actions.card-mode');
const tableContent = item.querySelector('.item-content.table-mode');
if (mode === 'table') {
if (cardContent) cardContent.style.display = 'none';
if (cardActions) cardActions.style.display = 'none';
if (tableContent) tableContent.style.display = 'block';
} else {
if (cardContent) cardContent.style.display = 'flex';
if (cardActions) cardActions.style.display = 'flex';
if (tableContent) tableContent.style.display = 'none';
}
});
tableHeader.style.display = mode === 'table' ? 'block' : 'none';
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(savedMode);
document.getElementById('viewModeToggle').addEventListener('click', function() {
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
const newMode = currentMode === 'card' ? 'table' : 'card';
setViewMode(newMode);
});
// Details button handlers
document.querySelectorAll('.details-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const itemId = this.dataset.itemId;
fetch(`/get_item_details/${itemId}`)
.then(r => r.json())
.then(data => {
if (data.success) {
displayLibraryItemDetail(data.item);
document.getElementById('detailModal').style.display = 'flex';
}
});
});
});
// Borrow button handlers
document.querySelectorAll('.borrow-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.getElementById('borrowItemId').value = this.dataset.itemId;
document.getElementById('borrowModal').style.display = 'flex';
});
});
// Borrow form submit
document.getElementById('borrowForm').addEventListener('submit', function(e) {
e.preventDefault();
const itemId = document.getElementById('borrowItemId').value;
const studentId = document.getElementById('studentIdInput').value || null;
const borrowDays = document.getElementById('borrowDaysInput').value || 14;
fetch('/borrow_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
item_id: itemId,
student_id: studentId,
borrow_days: borrowDays
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich ausgeliehen!');
location.reload();
} else {
alert('Fehler: ' + data.message);
}
});
});
// Modal close handlers
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
});
});
function displayLibraryItemDetail(item) {
const modalBody = document.getElementById('modalBody');
const returnBtn = item.Verfuegbar === false ? `<button class="action-button return-btn" onclick="returnItem('${item._id}')">Zurückgeben</button>` : '';
const borrowBtn = item.Verfuegbar === true ? `<button class="action-button borrow-btn" onclick="openBorrowModal('${item._id}')">Ausleihen</button>` : '';
modalBody.innerHTML = `
<h2>${item.Name}</h2>
<div class="detail-content">
${item.Image ? `<img src="/uploads/${item.Image}" alt="${item.Name}" style="max-width: 200px; margin-bottom: 20px;">` : ''}
<p><strong>Autor:</strong> ${item.Author || '—'}</p>
<p><strong>ISBN:</strong> ${item.ISBN || '—'}</p>
<p><strong>Typ:</strong> ${item.ItemType || 'Medium'}</p>
<p><strong>Status:</strong> ${item.Verfuegbar === false ? 'Ausgeliehen' : 'Verfügbar'}</p>
${item.User ? `<p><strong>Ausgeliehen von:</strong> ${item.User}</p>` : ''}
${item.Beschreibung ? `<p><strong>Beschreibung:</strong> ${item.Beschreibung}</p>` : ''}
<div class="modal-actions">
${borrowBtn}
${returnBtn}
</div>
</div>
`;
}
function openBorrowModal(itemId) {
document.getElementById('borrowItemId').value = itemId;
document.getElementById('borrowModal').style.display = 'flex';
document.getElementById('detailModal').style.display = 'none';
}
function returnItem(itemId) {
if (confirm('Wirklich zurückgeben?')) {
fetch('/return_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_id: itemId})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich zurückgegeben!');
location.reload();
}
});
}
}
// Filter dropdown toggle with auto-close
function closeAllFilters() {
document.querySelectorAll('.filter-dropdown').forEach(d => d.style.display = 'none');
document.getElementById('filterBtn').setAttribute('aria-expanded', 'false');
document.getElementById('filterBtn').classList.remove('open');
}
document.getElementById('filterBtn').addEventListener('click', function(e) {
e.stopPropagation();
const dropdown = document.getElementById('filterDropdown');
const isOpen = dropdown.style.display !== 'none';
closeAllFilters();
if (!isOpen) {
dropdown.style.display = 'block';
this.setAttribute('aria-expanded', 'true');
this.classList.add('open');
}
});
document.addEventListener('click', closeAllFilters);
document.getElementById('filterDropdown').addEventListener('click', e => e.stopPropagation());
// Clear filters
document.getElementById('clearFiltersBtn').addEventListener('click', function() {
document.querySelectorAll('.filter-checkbox').forEach(c => c.checked = false);
closeAllFilters();
});
// Favorites toggle
document.getElementById('favoriteToggle').addEventListener('click', function() {
this.classList.toggle('open');
// TODO: Implement favorites filtering
});
// Scanner toggle
let scanner = null;
document.getElementById('scannerBtn').addEventListener('click', function(e) {
e.stopPropagation();
const container = document.getElementById('qrContainer');
const isOpen = container.style.display !== 'none';
if (isOpen) {
container.style.display = 'none';
if (scanner) scanner.clear();
this.classList.remove('open');
this.setAttribute('aria-expanded', 'false');
} else {
container.style.display = 'block';
this.classList.add('open');
this.setAttribute('aria-expanded', 'true');
if (!scanner) {
scanner = new Html5Qrcode('qr-reader');
scanner.start(
{ facingMode: "environment" },
{ fps: 10, qrbox: 250 },
onScanSuccess,
onScanError
);
}
}
});
function onScanSuccess(decodedText, decodedResult) {
const studentId = decodedText.trim();
document.getElementById('studentIdInput').value = studentId;
alert('Ausweis gescannt: ' + studentId);
}
function onScanError(error) {
// Silently ignore scan errors
}
</script>
<style>
.library-item .item-image-wrapper {
width: 120px;
height: 160px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.library-item .placeholder-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
}
.library-item .item-author {
font-size: 0.9rem;
color: #666;
margin: 4px 0;
}
.library-item .item-isbn {
font-size: 0.85rem;
color: #999;
font-family: monospace;
margin: 4px 0;
}
.library-item .item-type {
display: inline-block;
background: #e8f0fe;
color: #1967d2;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.library-item .item-status.available {
background: #e6f4ea;
color: #137333;
}
.library-item .item-status.borrowed {
background: #fce8e6;
color: #b3261e;
}
</style>
{% endblock %}
+590
View File
@@ -0,0 +1,590 @@
{% extends "base.html" %}
{% block title %}Bibliotheks-Ausleihen - {{ APP_VERSION }}{% endblock %}
{% block content %}
<style>
.library-admin-shell {
max-width: 1460px;
margin: 0 auto;
padding: 18px 20px 32px;
}
.library-admin-hero {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 18px;
padding: 20px 22px;
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%);
border: 1px solid #dbe4ee;
border-radius: 18px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.library-admin-hero h1 {
margin: 0 0 6px 0;
font-size: 1.9rem;
}
.library-admin-hero p {
margin: 0;
color: #5b6472;
}
.hero-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.hero-actions .btn {
white-space: nowrap;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.summary-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.summary-card .label {
display: block;
font-size: 0.85rem;
color: #6b7280;
margin-bottom: 4px;
}
.summary-card .value {
font-size: 1.6rem;
font-weight: 800;
color: #1f2937;
}
.filter-bar {
display: grid;
grid-template-columns: 1fr 220px 220px;
gap: 10px;
margin-bottom: 16px;
}
.filter-bar input,
.filter-bar select {
width: 100%;
padding: 11px 14px;
border: 1px solid #d6dde6;
border-radius: 12px;
background: #fff;
}
.panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 18px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06);
margin-bottom: 18px;
}
.panel h2 {
margin: 0 0 12px 0;
font-size: 1.2rem;
}
.library-table {
width: 100%;
border-collapse: collapse;
}
.library-table th,
.library-table td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
vertical-align: top;
text-align: left;
}
.library-table th {
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: #f8fafc;
}
.library-table tr:hover td {
background: #fbfdff;
}
.mono {
font-family: monospace;
font-size: 0.92rem;
}
.badge-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
}
.badge-active { background: #dbeafe; color: #1d4ed8; }
.badge-planned { background: #fef3c7; color: #b45309; }
.badge-completed { background: #dcfce7; color: #166534; }
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; }
.badge-damaged { background: #fee2e2; color: #991b1b; }
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.row-actions form {
margin: 0;
}
.row-actions .btn {
white-space: nowrap;
}
.muted {
color: #6b7280;
font-size: 0.92rem;
}
.empty-state {
padding: 28px 16px;
text-align: center;
color: #6b7280;
}
@media (max-width: 900px) {
.library-admin-hero {
flex-direction: column;
}
.filter-bar {
grid-template-columns: 1fr;
}
.library-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>
<div class="library-admin-shell">
<div class="library-admin-hero">
<div>
<h1>Bibliotheks-Ausleihen</h1>
<p>Nur Bibliotheksmedien. Hier kannst du offene Ausleihen abschließen, Rechnungen bezahlen und defekte Medien direkt zurücksetzen.</p>
</div>
<div class="hero-actions">
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
<a class="btn btn-primary" href="{{ url_for('library_admin') }}">Bücher hochladen</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Alle Ausleihen</a>
</div>
</div>
<div class="summary-grid">
<div class="summary-card">
<span class="label">Ausleihen</span>
<div class="value">{{ loan_entries|length }}</div>
</div>
<div class="summary-card">
<span class="label">Defekte Medien</span>
<div class="value">{{ damaged_items|length }}</div>
</div>
<div class="summary-card">
<span class="label">Direkt reparierbar</span>
<div class="value">{{ damaged_items|length }}</div>
</div>
</div>
<div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Ausweis oder Rechnung suchen...">
<select id="loan-status-filter">
<option value="">Alle Ausleihen</option>
<option value="active">Aktiv</option>
<option value="planned">Geplant</option>
<option value="completed">Abgeschlossen</option>
</select>
<select id="damage-filter">
<option value="all">Alle Einträge</option>
<option value="damage">Nur defekt</option>
<option value="clean">Nur ohne Schaden</option>
</select>
</div>
<div class="panel">
<h2>Bibliotheks-Ausleihen</h2>
{% if loan_entries %}
<table class="library-table" id="loans-table">
<thead>
<tr>
<th>Status</th>
<th>Element</th>
<th>Benutzer</th>
<th>Zeit</th>
<th>Rechnung</th>
<th>Schaden</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for e in loan_entries %}
<tr class="loan-row" data-borrow-id="{{ e.id }}" data-item-id="{{ e.item_id }}" data-item-name="{{ e.item_name }}" data-item-code="{{ e.item_code }}" data-item-cost="{{ e.item_cost_raw }}" data-user-name="{{ e.user }}" data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}" data-status="{{ e.status }}" data-has-damage="{{ '1' if e.has_damage else '0' }}">
<td>
{% if e.status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span>
{% elif e.status == 'planned' %}
<span class="badge-pill badge-planned">Geplant</span>
{% else %}
<span class="badge-pill badge-completed">Abgeschlossen</span>
{% endif %}
</td>
<td>
<div><strong>{{ e.item_name }}</strong></div>
<div class="muted">{{ e.item_author or '—' }}</div>
<div class="mono">{{ e.item_code or '—' }}</div>
<div style="margin-top:6px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=e.item_id) }}">Rechnungen</a>
</div>
</td>
<td>
<div>{{ e.user }}</div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div>
</td>
<td>
<div>{{ e.period or '—' }}</div>
{% if e.notes %}<div class="muted">{{ e.notes }}</div>{% endif %}
</td>
<td>
{% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div>
<div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div>
{% if e.invoice_paid %}
<span class="badge-pill badge-paid">Bezahlt</span>
{% if e.invoice_paid_at %}<div class="muted">am {{ e.invoice_paid_at }}</div>{% endif %}
{% else %}
<span class="badge-pill badge-open">Offen</span>
{% endif %}
{% else %}
<span class="muted">Keine Rechnung</span>
{% endif %}
</td>
<td>
{% if e.has_damage %}
<span class="badge-pill badge-damaged">{{ e.damage_count }} Schaden{% if e.damage_count != 1 %}s{% endif %}</span>
{% if e.damage_text %}<div class="muted" style="margin-top:6px;">{{ e.damage_text }}</div>{% endif %}
{% else %}
<span class="muted">Kein Schaden</span>
{% endif %}
</td>
<td>
<div class="row-actions">
{% if e.status == 'active' %}
<button type="button" class="btn btn-outline-danger btn-sm" onclick="openDamageReportPrompt(this)">Schaden melden</button>
{% endif %}
{% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %}
<form method="post" action="{{ url_for('admin_finalize_invoice_and_repair', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt und Element als repariert markieren?');">
<button type="submit" class="btn btn-success btn-sm">
{% if not e.invoice_paid and e.has_damage %}
Bezahlt + repariert
{% elif not e.invoice_paid %}
Als bezahlt markieren
{% else %}
Als repariert markieren
{% endif %}
</button>
</form>
{% elif e.has_damage %}
<form method="post" action="{{ url_for('mark_damage_repaired', id=e.item_id) }}" onsubmit="return confirm('Medium als repariert markieren?');">
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form>
{% endif %}
{% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning btn-sm">Zurücksetzen</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="loans-empty" class="empty-state" style="display:none;">Keine passenden Bibliotheks-Ausleihen gefunden.</div>
{% else %}
<div class="empty-state">Keine Bibliotheks-Ausleihen gefunden.</div>
{% endif %}
</div>
<div class="panel">
<h2>Defekte Bibliotheksmedien ohne aktive Ausleihe</h2>
{% if damaged_items %}
<table class="library-table" id="damaged-table">
<thead>
<tr>
<th>Element</th>
<th>Code</th>
<th>Schaden</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for item in damaged_items %}
<tr class="damaged-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.author ~ ' ' ~ item.isbn ~ ' ' ~ item.damage_text)|lower }}" data-status="damage" data-has-damage="1">
<td>
<div><strong>{{ item.name }}</strong></div>
<div class="muted">{{ item.author or '—' }}</div>
<div class="muted">{{ item.isbn or '—' }}</div>
</td>
<td class="mono">{{ item.code or '—' }}</td>
<td>
<span class="badge-pill badge-damaged">{{ item.damage_count }} Schaden{% if item.damage_count != 1 %}s{% endif %}</span>
{% if item.damage_text %}<div class="muted" style="margin-top:6px;">{{ item.damage_text }}</div>{% endif %}
</td>
<td>
{% if item.available %}
<span class="badge-pill badge-completed">Bereit zum Zurücksetzen</span>
{% else %}
<span class="badge-pill badge-open">Nicht verfügbar</span>
{% endif %}
{% if item.last_updated %}<div class="muted" style="margin-top:6px;">{{ item.last_updated }}</div>{% endif %}
</td>
<td>
<div class="row-actions" style="margin-bottom:8px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a>
</div>
<form method="post" action="{{ url_for('mark_damage_repaired', id=item.id) }}" onsubmit="return confirm('Dieses Medium als repariert und wieder verfügbar markieren?');">
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="damaged-empty" class="empty-state" style="display:none;">Keine passenden defekten Medien gefunden.</div>
{% else %}
<div class="empty-state">Keine defekten Bibliotheksmedien ohne aktive Ausleihe gefunden.</div>
{% endif %}
</div>
</div>
<div id="damage-invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div>
<h2 style="margin:0;">Rechnung erstellen</h2>
<p style="margin:6px 0 0; color:#666;">Die Rechnung nutzt das bestehende Rechnungssystem und kann direkt nach der Schadensmeldung erstellt werden.</p>
</div>
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Schließen</button>
</div>
<form id="damage-invoice-form" method="post" action="">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div>
<label for="damage-invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="damage-invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="damage-invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
<input id="damage-invoice-amount" name="invoice_amount" type="text" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="z.B. 12,50">
</div>
</div>
<div style="margin-bottom:16px;">
<label for="damage-invoice-reason" style="display:block; font-weight:700; margin-bottom:6px;">Schadensbeschreibung</label>
<textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div>
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked>
Element als zerstört markieren
</label>
<label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-close" type="checkbox" name="close_borrowing" checked>
Ausleihe abschließen
</label>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button>
</div>
</form>
</div>
</div>
<script>
(function() {
const searchInput = document.getElementById('library-search');
const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row'));
const damagedRows = Array.from(document.querySelectorAll('.damaged-row'));
const loansEmpty = document.getElementById('loans-empty');
const damagedEmpty = document.getElementById('damaged-empty');
const damageInvoiceModal = document.getElementById('damage-invoice-modal');
const damageInvoiceForm = document.getElementById('damage-invoice-form');
const damageInvoiceItem = document.getElementById('damage-invoice-item');
const damageInvoiceBorrower = document.getElementById('damage-invoice-borrower');
const damageInvoiceCode = document.getElementById('damage-invoice-code');
const damageInvoiceAmount = document.getElementById('damage-invoice-amount');
const damageInvoiceReason = document.getElementById('damage-invoice-reason');
function openDamageReportPrompt(button) {
const row = button.closest('.loan-row');
if (!row) {
return;
}
const itemId = row.dataset.itemId || '';
const itemName = row.dataset.itemName || 'Bibliotheksmedium';
const noteInput = prompt('Schadensmeldung für dieses Bibliotheksmedium:\nNotiz zum Schaden (optional):', '');
if (noteInput === null) {
return;
}
const description = noteInput.trim() || 'Schaden erneut gemeldet';
fetch(`/report_damage/${itemId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description })
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
throw new Error(data.message || 'Fehler beim Speichern der Schadensmeldung.');
}
if (confirm('Schaden gespeichert. Soll direkt eine Rechnung erstellt werden?')) {
openDamageInvoiceModal(row, description);
return;
}
window.location.reload();
})
.catch(error => {
alert(error.message || 'Fehler beim Speichern der Schadensmeldung.');
});
}
window.openDamageReportPrompt = openDamageReportPrompt;
function openDamageInvoiceModal(row, description) {
if (!damageInvoiceModal || !damageInvoiceForm) {
return;
}
const borrowId = row.dataset.borrowId || '';
const itemName = row.dataset.itemName || '';
const borrower = row.dataset.userName || '';
const itemCode = row.dataset.itemCode || '';
const itemCost = row.dataset.itemCost || '';
damageInvoiceForm.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
damageInvoiceItem.value = itemName;
damageInvoiceBorrower.value = borrower;
damageInvoiceCode.value = itemCode;
damageInvoiceAmount.value = String(itemCost).replace(' EUR', '').trim();
damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`;
damageInvoiceModal.style.display = 'block';
damageInvoiceAmount.focus();
}
window.openDamageInvoiceModal = openDamageInvoiceModal;
function closeDamageInvoiceModal() {
if (damageInvoiceModal) {
damageInvoiceModal.style.display = 'none';
}
}
if (damageInvoiceModal) {
damageInvoiceModal.addEventListener('click', function(event) {
if (event.target === damageInvoiceModal) {
closeDamageInvoiceModal();
}
});
}
window.closeDamageInvoiceModal = closeDamageInvoiceModal;
function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value;
const damage = damageFilter.value;
let visibleLoans = 0;
loanRows.forEach(row => {
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status;
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
row.style.display = show ? '' : 'none';
if (show) visibleLoans++;
});
if (loansEmpty) loansEmpty.style.display = visibleLoans === 0 ? 'block' : 'none';
let visibleDamaged = 0;
damagedRows.forEach(row => {
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
row.style.display = show ? '' : 'none';
if (show) visibleDamaged++;
});
if (damagedEmpty) damagedEmpty.style.display = visibleDamaged === 0 ? 'block' : 'none';
}
searchInput.addEventListener('input', applyFilters);
statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters);
applyFilters();
})();
</script>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block title %}Rechnungen Element - {{ APP_VERSION }}{% endblock %}
{% block content %}
<style>
.invoice-history-shell {
max-width: 1180px;
margin: 0 auto;
padding: 20px;
}
.invoice-history-head {
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%);
border: 1px solid #dbe4ee;
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 16px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.07);
}
.invoice-history-head h1 {
margin: 0 0 6px 0;
font-size: 1.55rem;
}
.head-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
color: #4b5563;
font-size: 0.95rem;
margin-bottom: 12px;
}
.head-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.invoice-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 18px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.invoice-table {
width: 100%;
border-collapse: collapse;
}
.invoice-table th,
.invoice-table td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
vertical-align: top;
text-align: left;
}
.invoice-table th {
font-size: 0.83rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: #f8fafc;
}
.invoice-table tr:hover td {
background: #fbfdff;
}
.badge-pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
}
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; }
.badge-active { background: #dbeafe; color: #1d4ed8; }
.badge-completed { background: #dcfce7; color: #166534; }
.badge-planned { background: #fef3c7; color: #b45309; }
.mono {
font-family: monospace;
font-size: 0.92rem;
}
.muted {
color: #6b7280;
font-size: 0.9rem;
}
.empty-state {
text-align: center;
color: #6b7280;
padding: 36px 20px;
}
@media (max-width: 900px) {
.invoice-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>
<div class="invoice-history-shell">
<div class="invoice-history-head">
<h1>Rechnungshistorie pro Element</h1>
<div class="head-meta">
<span><strong>Element:</strong> {{ item.name or '—' }}</span>
<span><strong>Code:</strong> {{ item.code or '—' }}</span>
<span><strong>Autor:</strong> {{ item.author or '—' }}</span>
<span><strong>ISBN:</strong> {{ item.isbn or '—' }}</span>
</div>
<div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
</div>
</div>
<div class="invoice-card">
{% if invoices %}
<table class="invoice-table">
<thead>
<tr>
<th>Rechnung</th>
<th>Betrag</th>
<th>Ausleihe</th>
<th>Status</th>
<th>Schaden</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for row in invoices %}
<tr>
<td>
<div class="mono">{{ row.invoice_number or '—' }}</div>
<div class="muted">erstellt {{ row.invoice_created_at or '—' }}</div>
{% if row.invoice_created_by %}<div class="muted">von {{ row.invoice_created_by }}</div>{% endif %}
</td>
<td>{{ row.invoice_amount }}</td>
<td>
<div>{{ row.borrow_user or '—' }}</div>
<div class="muted">{{ row.borrow_start or '' }}{% if row.borrow_end %} bis {{ row.borrow_end }}{% endif %}</div>
{% if row.borrow_status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span>
{% elif row.borrow_status == 'planned' %}
<span class="badge-pill badge-planned">Geplant</span>
{% else %}
<span class="badge-pill badge-completed">Abgeschlossen</span>
{% endif %}
</td>
<td>
{% if row.invoice_paid %}
<span class="badge-pill badge-paid">Bezahlt</span>
{% if row.invoice_paid_at %}<div class="muted">am {{ row.invoice_paid_at }}</div>{% endif %}
{% if row.invoice_paid_by %}<div class="muted">durch {{ row.invoice_paid_by }}</div>{% endif %}
{% else %}
<span class="badge-pill badge-open">Offen</span>
{% endif %}
</td>
<td>
<div class="muted">{{ row.invoice_reason or 'Keine Beschreibung' }}</div>
</td>
<td>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=row.borrow_id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">Für dieses Element wurden noch keine Rechnungen gespeichert.</div>
{% endif %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+394
View File
@@ -0,0 +1,394 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}License - Inventarsystem{% endblock %}
{% block content %}
<div class="container">
<div class="content">
<h1>Lizenzinformationen &amp; Rechtliches</h1>
<!-- Tab navigation -->
<ul class="nav nav-tabs mt-3" id="legalTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-eula" data-bs-toggle="tab" data-bs-target="#pane-eula"
type="button" role="tab">EULA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-notice" data-bs-toggle="tab" data-bs-target="#pane-notice"
type="button" role="tab">NOTICE</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-privacy" data-bs-toggle="tab" data-bs-target="#pane-privacy"
type="button" role="tab">Datenschutz</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-security" data-bs-toggle="tab" data-bs-target="#pane-security"
type="button" role="tab">Sicherheit</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-data" data-bs-toggle="tab" data-bs-target="#pane-data"
type="button" role="tab">Datenverarbeitung</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
type="button" role="tab">Rechtsgrundlage</button>
</li>
</ul>
<div class="tab-content" id="legalTabContent">
<!-- ── EULA ─────────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="pane-eula" role="tabpanel">
<div class="license-content">
<!-- EULA -->
<h2>Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen</h2>
<p><strong>Softwareprojekt:</strong> Inventarsystem<br>
<strong>Urheberrechtshalter (Lizenzgeber):</strong> AIIrondev<br>
<strong>Gültigkeit:</strong> Stand 2026</p>
<hr>
<h3>Präambel</h3>
<p>Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag") stellt eine rechtsgültige Vereinbarung zwischen Ihnen
(im Folgenden „Lizenznehmer") und dem Urheber <strong>AIIrondev</strong> (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.</p>
<p>Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung,
Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.</p>
<hr>
<h3>§ 1 Gegenstand der Lizenz und Eigentumsrechte</h3>
<ol>
<li>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.</li>
<li>Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag
explizit genannten Bedingungen.</li>
</ol>
<h3>§ 2 Zulässiger Nutzungskreis (Privatnutzung)</h3>
<ol>
<li>Die unentgeltliche Nutzung des Produkts ist ausschließlich <strong>natürlichen Personen für den
rein privaten, häuslichen Gebrauch</strong> gestattet.</li>
<li>Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.</li>
<li>Jegliche Nutzung durch <strong>Institutionelle Nutzer</strong> (einschließlich, aber nicht
beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen,
Behörden oder NGOs) ist ausdrücklich <strong>untersagt</strong> und bedarf einer gesonderten,
schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.</li>
</ol>
<h3>§ 3 Funktionale Einschränkungen und Support</h3>
<ol>
<li>Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is").</li>
<li>Für die kostenlose Privatnutzung besteht <strong>kein Anspruch</strong> auf:
<ul>
<li>Technischen Support oder Beratung.</li>
<li>Bereitstellung von Sicherheits-Updates oder Patches.</li>
<li>Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.</li>
</ul>
</li>
<li>Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern,
einzuschränken oder kostenpflichtig zu gestalten.</li>
</ol>
<h3>§ 4 Wahrung der Urheberbezeichnung (Branding-Klausel)</h3>
<ol>
<li>Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung
<strong>„Powered by AIIrondev"</strong> in der Benutzeroberfläche (Footer/Menü).</li>
<li>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.</li>
<li>Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der
Nutzungslizenz.</li>
</ol>
<h3>§ 5 Verbot der kommerziellen Verwertung &amp; SaaS</h3>
<ol>
<li>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.</li>
<li>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.</li>
</ol>
<h3>§ 6 Modifikationen und Contributions</h3>
<ol>
<li>Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.</li>
<li>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.</li>
</ol>
<h3>§ 7 Haftungsbeschränkung</h3>
<ol>
<li>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.</li>
<li>Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten
Daten.</li>
</ol>
<h3>§ 8 Rechtswahl und Gerichtsstand</h3>
<ol>
<li>Es gilt ausschließlich das Recht der <strong>Bundesrepublik Deutschland</strong> unter Ausschluss
des UN-Kaufrechts (CISG).</li>
<li>Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der
Sitz des Lizenzgebers vereinbart.</li>
<li>Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen
Bestimmungen unberührt (Salvatorische Klausel).</li>
</ol>
<hr>
<div class="license-exception-notice">
<h3>⚠️ Anfragen für Ausnahmegenehmigungen (Kommerzielle Lizenzen)</h3>
<p>
Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">AIIrondev auf GitHub</a>
</p>
</div>
</div><!-- /.license-content -->
</div><!-- /#pane-eula -->
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
<div class="license-content">
<h2>NOTICE Urheberrechtliche Hinweise</h2>
<p><strong>Inventarsystem</strong><br>Copyright © 2025-2026 AIIrondev</p>
<p>Dieses Projekt steht unter dem Inventarsystem EULA (Endbenutzer-Lizenzvertrag). Der vollständige Lizenztext
befindet sich in <code>Legal/LICENSE</code>. 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:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">https://github.com/AIIrondev</a></p>
<hr>
<h3>Drittanbieter-Hinweise</h3>
<p>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.</p>
<h4>Python-Pakete (Laufzeit)</h4>
<ul>
<li>Flask (BSD-3-Clause) © Pallets</li>
<li>Werkzeug (BSD-3-Clause) © Pallets</li>
<li>Jinja2 (BSD-3-Clause) © Pallets (indirekt via Flask)</li>
<li>Click (BSD-3-Clause) © Pallets (indirekt via Flask)</li>
<li>Gunicorn (MIT) © 2009-2025 Benoit Chesneau and contributors</li>
<li>PyMongo (Apache-2.0) © MongoDB, Inc.</li>
<li>APScheduler (MIT) © 2009-2025 Alex Grönholm and contributors</li>
<li>Pillow (HPND/FreeType-style) © 1995-2011 Fredrik Lundh; © 2010-2025 Alex Clark and contributors</li>
<li>python-dateutil (BSD-3-Clause) © 2017-2025 dateutil team; © 2003-2011 Gustavo Niemeyer</li>
<li>pytz (MIT) © 2003-2025 Stuart Bishop</li>
<li>requests (Apache-2.0) © 2011-2025 Kenneth Reitz and contributors</li>
<li>qrcode (BSD-3-Clause) © 2010-2025 Lincoln Loop and contributors</li>
</ul>
<h4>Frontend / Laufzeit-Assets</h4>
<ul>
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
</ul>
<h4>Systemkomponenten (nicht im Repository enthalten, ggf. im Deployment genutzt)</h4>
<ul>
<li>NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors</li>
<li>FFmpeg (LGPL/GPL, je nach Konfiguration) © 2000-2025 FFmpeg developers</li>
</ul>
<hr>
</div>
</div><!-- /#pane-notice -->
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-privacy" role="tabpanel">
<div class="license-content">
<h2>Datenschutzerklärung (Privacy Policy)</h2>
<h3>1. Einleitung</h3>
<p>Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Dieses Dokument erläutert, wie das
„Inventarsystem" personenbezogene Daten verarbeitet.</p>
<h3>2. Verantwortliche Stelle</h3>
<p>Verantwortlich für die Datenverarbeitung ist der Betreiber der jeweiligen Instanz (Self-Hosted). Bei Fragen
wenden Sie sich bitte an den Administrator Ihrer Installation.</p>
<h3>3. Erhobene Daten</h3>
<p>Das System verarbeitet folgende Kategorien von Daten:</p>
<ul>
<li><strong>Benutzerdaten:</strong> Benutzername, (je nach Nutzer/Administrator Name und Nachname).</li>
<li><strong>Inventardaten:</strong> Bezeichnungen von Gegenständen, Mengen, Standorte, ggf. Zuordnungen zu Personen.</li>
<li><strong>Protokolldaten:</strong> Zeitstempel von Änderungen (Logs), IP-Adressen (serverseitig zur Sicherheit).</li>
</ul>
<h3>4. Zweck der Verarbeitung</h3>
<p>Die Daten werden ausschließlich zu folgenden Zwecken verarbeitet:</p>
<ul>
<li>Verwaltung und Organisation von Beständen.</li>
<li>Sicherstellung der Systemsicherheit.</li>
<li>Nachvollziehbarkeit von Bestandsänderungen.</li>
</ul>
<h3>5. Rechtsgrundlage (DSGVO)</h3>
<p>Die Verarbeitung erfolgt auf Basis von:</p>
<ul>
<li><strong>Art. 6 Abs. 1 lit. b DSGVO:</strong> Erfüllung eines Vertrages oder vorvertraglicher Maßnahmen.</li>
<li><strong>Art. 6 Abs. 1 lit. f DSGVO:</strong> Berechtigtes Interesse (effiziente Bestandsverwaltung).</li>
</ul>
<h3>6. Datenlöschung</h3>
<p>Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind,
sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.</p>
</div>
</div><!-- /#pane-privacy -->
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-security" role="tabpanel">
<div class="license-content">
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
<h3>Unterstützte Versionen</h3>
<p>Bitte nutzen Sie immer die neueste Version aus dem <code>main</code>-Branch, um Sicherheitsupdates zu erhalten.</p>
<h3>Datenschutzrechtliche Mechanismen</h3>
<ul>
<li><strong>Passwort-Hashing:</strong> Passwörter werden niemals im Klartext gespeichert.</li>
<li><strong>Session-Management:</strong> Automatisches Logout nach Inaktivität (konfigurierbar).</li>
<li><strong>Berechtigungskonzept:</strong> Rollenbasierte Zugriffskontrolle (RBAC), um den Zugriff auf
„Need-to-know"-Basis zu beschränken.</li>
</ul>
<h3>Meldung von Sicherheitslücken</h3>
<div class="license-exception-notice">
<h3>⚠️ Responsible Disclosure</h3>
<p>Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte <strong>kein öffentliches Issue</strong>.
Kontaktieren Sie den Maintainer direkt über die
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">GitHub Security Advisory-Funktion</a>
oder per E-Mail.</p>
</div>
</div>
</div><!-- /#pane-security -->
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-data" role="tabpanel">
<div class="license-content">
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
<h3>Systemübersicht</h3>
<p>Das <strong>Inventarsystem</strong> ist eine Anwendung zur Erfassung und Verwaltung von physischen Gütern.</p>
<h3>Datenfluss</h3>
<ol>
<li><strong>Eingabe:</strong> Nutzer gibt Daten über das Frontend/API ein.</li>
<li><strong>Speicherung:</strong> Daten werden in einer lokalen Datenbank verschlüsselt abgelegt.</li>
<li><strong>Ausgabe:</strong> Anzeige der Bestände für autorisierte Nutzer.</li>
</ol>
<h3>Technische und Organisatorische Maßnahmen (TOM)</h3>
<ul>
<li><strong>Zugangskontrolle:</strong> Authentifizierung über Benutzerkonten.</li>
<li><strong>Integrität:</strong> Validierung der Eingabedaten zur Vermeidung von Datenbankfehlern.</li>
<li><strong>Verfügbarkeit:</strong> Empfohlene Backup-Strategien für die Datenbank.</li>
</ul>
<h3>Empfänger der Daten</h3>
<p>Die Daten verbleiben innerhalb der kontrollierten Umgebung der Installation. Es findet kein automatisierter
Export an Dritte statt.</p>
</div>
</div><!-- /#pane-data -->
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-legal" role="tabpanel">
<div class="license-content">
<h2>Rechtsgrundlage der Nutzung</h2>
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
<ol>
<li><strong>Innerbetriebliche Nutzung:</strong> Die Verarbeitung ist zur Durchführung des
Beschäftigungsverhältnisses erforderlich (§ 26 BDSG / Art. 88 DSGVO).</li>
<li><strong>Berechtigtes Interesse:</strong> Das Unternehmen hat ein berechtigtes Interesse daran, zu wissen,
wo sich Arbeitsmittel befinden (Art. 6 Abs. 1 lit. f DSGVO).</li>
<li><strong>Einwilligung:</strong> Falls private Daten der Nutzer erfasst werden, muss eine explizite
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
</ol>
<div class="license-exception-notice" style="background-color:#f8d7da; border-color:#f5c2c7; border-left-color:#dc3545;">
<h3 style="color:#842029;">⚠️ Hinweis</h3>
<p>Dieses Dokument stellt <strong>keine Rechtsberatung</strong> dar. Bitte konsultieren Sie im Zweifelsfall
einen Fachanwalt für Datenschutzrecht.</p>
</div>
</div>
</div><!-- /#pane-legal -->
</div><!-- /.tab-content -->
</div>
</div>
<style>
#legalTabs .nav-link {
color: #495057;
}
#legalTabs .nav-link.active {
font-weight: 600;
color: #0d6efd;
}
.license-content {
background-color: #f8f9fa;
padding: 20px 30px;
border-radius: 0 0 5px 5px;
border: 1px solid #dee2e6;
border-top: none;
}
.license-content h2 {
margin-top: 10px;
}
.license-content h3 {
margin-top: 1.4em;
color: #343a40;
}
.license-content h4 {
margin-top: 1.1em;
color: #495057;
}
.license-content ol,
.license-content ul {
padding-left: 1.5em;
line-height: 1.7;
}
.license-content li {
margin-bottom: 0.4em;
}
.license-content hr {
margin: 25px 0;
border-color: #dee2e6;
}
.license-exception-notice {
margin: 20px 0;
padding: 15px 20px;
background-color: #fff3cd;
border: 1px solid #ffc107;
border-left: 5px solid #ffc107;
border-radius: 5px;
}
.license-exception-notice h3 {
margin-top: 0;
color: #856404;
}
.license-exception-notice p {
margin-bottom: 0;
}
.license-exception-notice a {
font-weight: bold;
color: #0d6efd;
}
</style>
{% endblock %}
+126
View File
@@ -0,0 +1,126 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<h2 class="text-center">Login</h2>
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="password-input-container">
<input type="password" class="form-control" id="password" name="password" required>
<button type="button" class="password-toggle-btn" id="togglePassword" aria-label="Show password">
<input type="checkbox" checked="checked">
<svg class="eye" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 576 512"><path d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"></path></svg>
<svg class="eye-slash" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512"><path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"></path></svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const togglePassword = document.getElementById('togglePassword');
const passwordInput = document.getElementById('password');
const passwordContainer = document.querySelector('.password-input-container');
togglePassword.addEventListener('click', function() {
// Toggle the password field type
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
// Toggle the visual state of the container
passwordContainer.classList.toggle('password-visible');
//Toggle visability of eye according to state
// Update aria-label for accessibility
const isVisible = type === 'text';
togglePassword.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
});
});
</script>
<style>
.password-input-container {
display: flex;
gap: 10px;
}
#togglePassword {
border-radius: 5px;
}
#togglePassword {
--color: #a5a5b0;
--size: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
font-size: var(--size);
user-select: none;
fill: var(--color);
width: 15%;
}
#togglePassword .eye-slash {
position: absolute;
animation: keyframes-fill .5s;
display: none;
}
#togglePassword .eye {
position: absolute;
animation: keyframes-fill .5s;
}
/* ------ On check event ------ */
#togglePassword input:checked ~ .eye-slash {
display: block;
}
#togglePassword input:checked ~ .eye {
display: none;
}
/* ------ Hide the default checkbox ------ */
#togglePassword input {
position: absolute;
z-index: 100;
opacity: 0;
cursor: pointer;
height: 100%;
width: 100%;
}
/* ------ Animation ------ */
@keyframes keyframes-fill {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
}
</style>
{% endblock %}
+378
View File
@@ -0,0 +1,378 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}System Logs{% endblock %}
{% block content %}
<div class="container">
<h1>System-Protokoll</h1>
{% set conflicts = items | selectattr('ConflictDetected') | list %}
{% if conflicts %}
<div class="conflict-summary-banner" id="conflictSummaryBanner">
<strong>⚠ {{ conflicts | length }} Buchungskonflikt{{ 's' if conflicts | length > 1 else '' }} erkannt</strong>
Geplante Reservierungen wurden aktiv, obwohl der Gegenstand bereits ausgeliehen war.
<button onclick="document.getElementById('conflictSummaryBanner').style.display='none'" class="banner-close"></button>
</div>
{% endif %}
<div class="filter-controls">
<input type="text" id="searchInput" placeholder="Suchen..." onkeyup="filterTable()">
<div class="date-range">
<label for="startDate">Von:</label>
<input type="date" id="startDate" onchange="filterTable()">
<label for="endDate">Bis:</label>
<input type="date" id="endDate" onchange="filterTable()">
</div>
</div>
<div class="table-container">
<table id="logsTable">
<thead>
<tr>
<th onclick="sortTable(0)">Typ ↕</th>
<th onclick="sortTable(1)">Item Name ↕</th>
<th onclick="sortTable(2)">Benutzer ↕</th>
<th onclick="sortTable(3)">Status ↕</th>
<th onclick="sortTable(4)">Ausgeliehen am ↕</th>
<th onclick="sortTable(5)">Zurückgegeben am ↕</th>
<th>Dauer</th>
<th>Hinweis</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.EventType or 'Ausleihe' }}</td>
<td>{{ item.Item }}</td>
<td>{{ item.User }}</td>
<td>
<span class="status-badge status-{{ item.Status }}">{{ item.Status }}</span>
</td>
<td>{{ item.Start }}</td>
<td>{{ item.End }}</td>
<td>{{ item.Duration }}</td>
<td>
{% if item.ConflictNote %}
<span title="{{ item.ConflictNote }}">{{ item.ConflictNote }}</span>
{% endif %}
{% if item.ConflictDetected %}
<span class="conflict-badge" title="{{ item.ConflictNote }}">⚠ Konflikt</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="navigation-buttons">
<a href="{{ url_for('home') }}" class="button">Zurück zum Dashboard</a>
<button onclick="exportToCSV()" class="export-button">Als CSV exportieren</button>
</div>
</div>
<script>
function filterTable() {
const input = document.getElementById('searchInput').value.toLowerCase();
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const table = document.getElementById('logsTable');
const rows = table.getElementsByTagName('tr');
for (let i = 1; i < rows.length; i++) {
let show = true;
const cells = rows[i].getElementsByTagName('td');
// Text search
if (input) {
let textMatch = false;
for (let j = 0; j < cells.length; j++) {
if (cells[j].textContent.toLowerCase().indexOf(input) > -1) {
textMatch = true;
break;
}
}
show = textMatch;
}
// Date range filtering (uses Start in column 4 and End in column 5)
if (show && startDate) {
const rowDate = new Date(cells[4].textContent);
show = rowDate >= new Date(startDate);
}
if (show && endDate) {
const rowDate = new Date(cells[5].textContent);
show = rowDate <= new Date(endDate);
}
rows[i].style.display = show ? '' : 'none';
}
}
function sortTable(n) {
const table = document.getElementById('logsTable');
let switching = true;
let dir = 'asc';
let switchcount = 0;
while (switching) {
switching = false;
const rows = table.rows;
for (let i = 1; i < rows.length - 1; i++) {
let shouldSwitch = false;
const x = rows[i].getElementsByTagName('td')[n];
const y = rows[i + 1].getElementsByTagName('td')[n];
// Check if date column for proper date comparison (Start=4, End=5)
if (n === 4 || n === 5) {
const dateX = new Date(x.innerHTML);
const dateY = new Date(y.innerHTML);
if (dir === 'asc') {
if (dateX > dateY) {
shouldSwitch = true;
break;
}
} else if (dir === 'desc') {
if (dateX < dateY) {
shouldSwitch = true;
break;
}
}
} else {
if (dir === 'asc') {
if (x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) {
shouldSwitch = true;
break;
}
} else if (dir === 'desc') {
if (x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) {
shouldSwitch = true;
break;
}
}
}
}
if (shouldSwitch) {
rows[i].parentNode.insertBefore(rows[i + 1], rows[i]);
switching = true;
switchcount++;
} else {
if (switchcount === 0 && dir === 'asc') {
dir = 'desc';
switching = true;
}
}
}
}
function exportToCSV() {
const table = document.getElementById('logsTable');
let csv = [];
const rows = table.querySelectorAll('tr');
for (let i = 0; i < rows.length; i++) {
const row = [], cols = rows[i].querySelectorAll('td, th');
for (let j = 0; j < cols.length; j++) {
// Escape double-quotes with double-quotes
let data = cols[j].innerText.replace(/"/g, '""');
// Add quotes if the content contains comma or newline
row.push('"' + data + '"');
}
csv.push(row.join(','));
}
// Download CSV file
const csvFile = new Blob([csv.join('\n')], {type: 'text/csv'});
const downloadLink = document.createElement('a');
downloadLink.download = 'ausleihungen_' + new Date().toISOString().slice(0,10) + '.csv';
downloadLink.href = window.URL.createObjectURL(csvFile);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
}
</script>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.filter-controls {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
}
#searchInput {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
width: 200px;
}
.date-range {
display: flex;
align-items: center;
gap: 10px;
}
.date-range input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: capitalize;
color: #fff;
}
.status-planned { background-color: #6c757d; }
.status-active { background-color: #17a2b8; }
.status-completed { background-color: #28a745; }
.status-cancelled { background-color: #dc3545; }
.status-gemeldet { background-color: #fd7e14; }
.status-repariert { background-color: #20c997; }
.conflict-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
cursor: help;
white-space: nowrap;
}
.conflict-summary-banner {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-left: 4px solid #fd7e14;
color: #856404;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.banner-close {
background: none;
border: none;
font-size: 1rem;
cursor: pointer;
color: #856404;
flex-shrink: 0;
padding: 0 4px;
}
.banner-close:hover { color: #533f03; }
.table-container {
overflow-x: auto;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #007bff;
color: white;
cursor: pointer;
}
th:hover {
background-color: #0056b3;
}
tbody tr:hover {
background-color: #f5f5f5;
}
.navigation-buttons {
display: flex;
justify-content: space-between;
}
.button, .export-button {
padding: 10px 15px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.button:hover, .export-button:hover {
background-color: #0056b3;
}
.export-button {
background-color: #28a745;
}
.export-button:hover {
background-color: #218838;
}
@media (max-width: 768px) {
.filter-controls {
flex-direction: column;
gap: 10px;
}
#searchInput {
width: 100%;
}
.date-range {
width: 100%;
justify-content: space-between;
}
}
</style>
{% endblock %}
+4170
View File
File diff suppressed because it is too large Load Diff
+4987
View File
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Filter verwalten{% endblock %}
{% block content %}
<div class="container">
<h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row">
<!-- Filter 1: Unterrichtsfach -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neues Unterrichtsfach..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter1_values %}
<div class="list-group">
{% for value in filter1_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
<!-- Filter 2: Jahrgangsstufe -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neue Jahrgangsstufe..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter2_values %}
<div class="list-group">
{% for value in filter2_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="alert alert-warning">
<strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht aus bestehenden Objekten entfernt.
Bereits verwendete Werte bleiben in den Objekten erhalten.
</div>
<div class="mt-4">
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div>
</div>
{% endblock %}
+200
View File
@@ -0,0 +1,200 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Orte verwalten{% endblock %}
{% block content %}
<div class="container">
<h1>Orte verwalten</h1>
<div class="card">
<div class="card-header">
<h2>Neuen Ort hinzufügen</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_location_value') }}">
<div class="form-group">
<label for="location-value">Ort:</label>
<input type="text" id="location-value" name="value" required placeholder="Neuen Ort eingeben">
</div>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h2>Vorhandene Orte</h2>
</div>
<div class="card-body">
{% if location_values %}
<div class="location-values-list">
{% for value in location_values %}
<div class="location-value-item">
<span class="location-value">{{ value }}</span>
<form method="POST" action="{{ url_for('remove_location_value', value=value) }}" class="inline-form">
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Sind Sie sicher, dass Sie diesen Ort entfernen möchten?')">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-values-message">
Es wurden noch keine Orte definiert.
</div>
{% endif %}
</div>
</div>
<div class="actions-container">
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Übersicht</a>
</div>
</div>
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #343a40;
}
.card {
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
margin-bottom: 20px;
background-color: #fff;
border: 1px solid #e9ecef;
}
.card-header {
background-color: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
}
.card-header h2 {
margin: 0;
font-size: 1.25rem;
color: #495057;
}
.card-body {
padding: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
border: none;
cursor: pointer;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0069d9;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.875rem;
}
.mt-4 {
margin-top: 1.5rem;
}
.location-values-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.location-value-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
border-left: 3px solid #6c757d;
}
.location-value {
font-weight: 500;
color: #495057;
}
.no-values-message {
color: #6c757d;
font-style: italic;
text-align: center;
padding: 20px;
}
.inline-form {
display: inline;
}
.actions-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
{% endblock %}
+642
View File
@@ -0,0 +1,642 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Meine Ausleihungen{% endblock %}
{% block content %}
<style>
/* ...existing styles... */
/* Reset button styling for item cards */
.item-reset-button {
background-color: #ffc107;
color: #212529;
border: 2px solid #ffc107;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.85rem;
text-decoration: none;
display: inline-block;
text-align: center;
transition: all 0.3s ease;
margin-left: 10px;
min-width: 90px;
}
.item-reset-button:hover {
background-color: #e0a800;
border-color: #d39e00;
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(255, 193, 7, 0.3);
}
.item-reset-button:active {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(255, 193, 7, 0.3);
}
/* Admin-only indicator */
.admin-only-button {
opacity: 0.7;
pointer-events: none;
}
.admin-only-tooltip {
position: relative;
display: inline-block;
}
.admin-only-tooltip:hover::after {
content: "Nur für Administratoren verfügbar";
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
}
/* Card action buttons container */
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
align-items: center;
}
.card-actions form {
display: inline-block;
margin: 0;
}
/* Update action-buttons-container to also use flex layout */
.action-buttons-container {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.action-buttons-container form {
display: inline-block;
margin: 0;
}
/* Ensure buttons have consistent spacing */
.action-buttons-container .return-button,
.action-buttons-container .cancel-button {
margin-right: 0;
flex: 0 0 auto;
width: auto;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.card-actions {
flex-direction: column;
align-items: stretch;
}
.item-reset-button {
margin-left: 0;
margin-top: 5px;
}
}
</style>
<div class="container">
<div class="content">
<h1>Meine aktuellen Ausleihungen</h1>
{% if not items and not planned_items %}
<div class="no-items-message">
Sie haben derzeit keine Objekte ausgeliehen oder geplant.
</div>
{% else %}
{% if items %}
<h2 class="section-title">Aktive Ausleihungen</h2>
<div class="borrowed-items">
{% for item in items %}
<div class="borrowed-item">
<div class="item-header">
<h3>{{ item.Name }}</h3>
{% if item.UserExemplarCount is defined and item.UserExemplarCount > 0 %}
<span class="exemplar-count">{{ item.UserExemplarCount }} Exemplar(e)</span>
{% endif %}
{% if item.ActiveAppointment %}
<span class="appointment-status status-active">Aktiv</span>
{% endif %}
</div>
<div class="item-details">
<div class="item-info">
<p><strong>Ort:</strong> {{ item.Ort or '-' }}</p>
{% if item.Filter is defined %}
<p><strong>Unterrichtsfach:</strong>
{% if item.Filter is string %}
{{ item.Filter }}
{% elif item.Filter | length > 0 %}
{{ item.Filter | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
{% if item.Filter2 is defined %}
<p><strong>Jahrgangsstufe:</strong>
{% if item.Filter2 is string %}
{{ item.Filter2 }}
{% elif item.Filter2 | length > 0 %}
{{ item.Filter2 | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
<p><strong>Barcode:</strong> {{ item.Code_4 or '-' }}</p>
{% if item.UserExemplars is defined and item.UserExemplars %}
<div class="exemplar-details">
<p><strong>Ausgeliehene Exemplare:</strong></p>
<ul class="exemplar-list">
{% for exemplar in item.UserExemplars %}
<li>
Exemplar {{ exemplar.number }}
{% if exemplar.date %}<span class="borrow-date">(seit {{ exemplar.date }})</span>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if item.Images and item.Images | length > 0 %}
<div class="item-image">
<img src="{{ url_for('uploaded_file', filename=item.Images[0]) }}" alt="{{ item.Name }}">
</div>
{% endif %}
</div>
<div class="item-actions">
{% if item.AppointmentData is defined and item.AppointmentData %}
<!-- Item with appointment data (from active/planned appointments) -->
<div class="action-buttons-container">
<form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}">
<input type="hidden" name="source_page" value="my_borrowed_items">
<button class="return-button" type="submit">Zurückgeben</button>
</form>
<form method="POST" action="{{ url_for('cancel_ausleihung_route', id=item.AppointmentData.id) }}" onsubmit="return confirm('Möchten Sie diese aktive Ausleihung wirklich stornieren?');">
<button class="cancel-button" type="submit">Stornieren</button>
</form>
<!-- Add reset button for admins -->
{% if user_is_admin %}
<button type="button"
class="item-reset-button"
onclick="resetItemBorrowingStatus('{{ item._id }}')"
title="Status des Items zurücksetzen">
Status zurücksetzen
</button>
{% endif %}
</div>
{% else %}
<!-- Regular borrowed item without appointment data -->
<div class="card-actions">
<form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}">
<input type="hidden" name="source_page" value="my_borrowed_items">
{% if item.UserExemplarCount is defined and item.UserExemplarCount > 1 %}
<div class="return-count-group">
<label for="exemplare_count_{{ loop.index }}">Anzahl zurückgeben:</label>
<input type="number" id="exemplare_count_{{ loop.index }}" name="exemplare_count"
min="1" max="{{ item.UserExemplarCount }}" value="1" class="exemplare-count-input">
</div>
{% endif %}
<button class="return-button" type="submit">Zurückgeben</button>
</form>
<!-- Add reset button for every borrowed item -->
{% if user_is_admin %}
<button type="button"
class="item-reset-button"
onclick="resetItemBorrowingStatus('{{ item._id }}')"
title="Status des Items zurücksetzen">
Status zurücksetzen
</button>
{% else %}
<div class="admin-only-tooltip">
<button type="button"
class="item-reset-button admin-only-button"
title="Nur für Administratoren verfügbar">
Status zurücksetzen
</button>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if planned_items %}
<h2 class="section-title">Geplante Ausleihungen</h2>
<div class="borrowed-items planned-items">
{% for item in planned_items %}
<div class="borrowed-item planned-item">
<div class="item-header">
<h3>{{ item.Name }}</h3>
{% set status = item.AppointmentData.status | default('planned') %}
<span class="appointment-status status-{{ status }}">
{% if status == 'planned' %}
Geplant
{% elif status == 'active' %}
Aktiv
{% elif status == 'completed' %}
Abgeschlossen
{% elif status == 'cancelled' %}
Storniert
{% else %}
{{ status }}
{% endif %}
</span>
</div>
<div class="item-details">
<div class="item-info">
<p><strong>Ort:</strong> {{ item.Ort or '-' }}</p>
{% if item.Filter is defined %}
<p><strong>Unterrichtsfach:</strong>
{% if item.Filter is string %}
{{ item.Filter }}
{% elif item.Filter | length > 0 %}
{{ item.Filter | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
{% if item.Filter2 is defined %}
<p><strong>Jahrgangsstufe:</strong>
{% if item.Filter2 is string %}
{{ item.Filter2 }}
{% elif item.Filter2 | length > 0 %}
{{ item.Filter2 | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
<p><strong>Barcode:</strong> {{ item.Code_4 or '-' }}</p>
<div class="appointment-details">
<p><strong>Termin:</strong></p>
{% set start_date = item.AppointmentData.start %}
{% set end_date = item.AppointmentData.end %}
<p class="appointment-date">
{% if item.AppointmentData.period %}
{% if start_date %}{{ start_date.strftime('%d.%m.%Y') }}{% else %}Unbekannt{% endif %}
<span class="appointment-period">{{ item.AppointmentData.period }}. Stunde</span>
{% 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') }}
<span class="appointment-period">Mehrtägig</span>
{% else %}
{% if start_date %}{{ start_date.strftime('%d.%m.%Y') }}{% else %}Unbekannt{% endif %}
{% endif %}
{% endif %}
</p>
{% if item.AppointmentData.notes %}
<p class="appointment-notes"><i>{{ item.AppointmentData.notes }}</i></p>
{% endif %}
</div>
</div>
{% if item.Images and item.Images | length > 0 %}
<div class="item-image">
<img src="{{ url_for('uploaded_file', filename=item.Images[0]) }}" alt="{{ item.Name }}">
</div>
{% endif %}
</div>
<div class="item-actions">
<div class="card-actions">
<form method="POST" action="{{ url_for('cancel_ausleihung_route', id=item.AppointmentData.id) }}" onsubmit="return confirm('Möchten Sie diesen geplanten Termin wirklich stornieren?');">
<button class="cancel-button" type="submit">Stornieren</button>
</form>
<!-- Add reset button for planned items too -->
{% if user_is_admin %}
<button type="button"
class="item-reset-button"
onclick="resetItemBorrowingStatus('{{ item._id }}')"
title="Status des Items zurücksetzen">
Status zurücksetzen
</button>
{% else %}
<div class="admin-only-tooltip">
<button type="button"
class="item-reset-button admin-only-button"
title="Nur für Administratoren verfügbar">
Status zurücksetzen
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</div>
<!-- Include reset item functions -->
{% include "reset_item_functions.html" %}
<!-- Include the new reset modal -->
{% include "reset_item_modal.html" %}
<!-- Add user admin status check -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Check if current user is admin and store globally
fetch('/user_status')
.then(response => response.json())
.then(data => {
window.currentUserIsAdmin = data.is_admin || false;
})
.catch(error => {
console.error('Error checking user status:', error);
window.currentUserIsAdmin = false;
});
});
</script>
<style>
.borrowed-items {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.borrowed-item {
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: 1px solid #e9ecef;
padding-bottom: 10px;
}
.item-header h3 {
margin: 0;
color: #343a40;
}
.exemplar-count {
background-color: #17a2b8;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
}
.item-details {
display: flex;
gap: 20px;
}
.item-info {
flex: 1;
}
.item-image {
flex: 0 0 150px;
}
.item-image img {
width: 100%;
max-height: 150px;
object-fit: cover;
border-radius: 4px;
}
.exemplar-details {
margin-top: 10px;
background-color: #f8f9fa;
padding: 10px;
border-radius: 5px;
border-left: 3px solid #6c757d;
}
.exemplar-list {
margin: 5px 0 0 0;
padding-left: 20px;
}
.exemplar-list li {
margin-bottom: 5px;
}
.borrow-date {
color: #6c757d;
font-size: 0.9em;
font-style: italic;
}
.item-actions {
margin-top: 15px;
display: flex;
justify-content: flex-end;
}
.return-button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.return-button:hover {
background-color: #0069d9;
}
.return-count-group {
display: flex;
align-items: center;
gap: 10px;
margin-right: 15px;
}
.exemplare-count-input {
width: 60px;
padding: 5px;
border: 1px solid #ced4da;
border-radius: 4px;
}
.no-items-message {
text-align: center;
padding: 30px;
background-color: #f8f9fa;
border-radius: 5px;
color: #6c757d;
margin-top: 20px;
}
/* Planned items styling */
.section-title {
margin-top: 30px;
margin-bottom: 20px;
font-size: 1.5rem;
color: #343a40;
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
}
.planned-badge {
background-color: #17a2b8;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
}
.planned-item {
border-left: 4px solid #17a2b8;
}
.appointment-details {
margin-top: 10px;
background-color: #e1f5fe;
padding: 10px;
border-radius: 5px;
border-left: 3px solid #17a2b8;
}
.appointment-date {
font-weight: bold;
color: #0277bd;
}
.appointment-period {
font-weight: normal;
font-style: italic;
margin-left: 10px;
}
.appointment-notes {
margin-top: 5px;
font-style: italic;
}
.action-buttons-container {
display: flex;
gap: 10px;
justify-content: space-between;
}
.action-buttons-container form {
flex: 1;
}
.action-buttons-container .return-button,
.action-buttons-container .cancel-button {
width: 100%;
}
.cancel-button {
background-color: #dc3545;
color: white;
border: none;
border-radius: 5px;
padding: 10px 15px;
cursor: pointer;
font-weight: bold;
}
.cancel-button:hover {
background-color: #c82333;
}
.appointment-status {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.8em;
font-weight: bold;
color: white;
margin-left: 10px;
}
.status-planned {
background-color: #03a9f4;
}
.status-active {
background-color: #4CAF50;
}
.status-completed {
background-color: #9E9E9E;
}
.status-cancelled {
background-color: #F44336;
}
/* Responsive layout */
@media (max-width: 768px) {
.item-details {
flex-direction: column-reverse;
}
.item-image {
flex: 0 0 auto;
text-align: center;
margin-bottom: 15px;
}
.item-image img {
max-width: 250px;
}
}
</style>
{% endblock %}
+338
View File
@@ -0,0 +1,338 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<div class="container">
<div class="header-section">
<h1>Neuen Benutzer registrieren</h1>
<p class="subtitle">Erstellen Sie ein neues Benutzerkonto und legen Sie Zugriffsrechte fest</p>
</div>
<div class="flash-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">
<span class="flash-icon">{% if category == 'success' %}✓{% else %}!{% endif %}</span>
<span class="flash-message">{{ message }}</span>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<div class="content">
<div class="form-card">
<form method="POST" action="{{ url_for('register') }}">
<div class="form-group">
<label for="username">Benutzername</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="username" name="username" placeholder="Geben Sie einen Benutzernamen ein" required>
</div>
<label for="name">Name</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Namen ein" required>
</div>
<label for="last-name">Nachname</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required>
</div>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<a class="richtlinen">Das Password muss mindestens 6 Zeichen beinhalten mit Sonderzeichen, groß und klein Buchstaben sowie Zahlen!</a>
<div class="input-container">
<span class="input-icon">🔒</span>
<input type="password" id="password" name="password" placeholder="Geben Sie ein sicheres Passwort ein" required>
</div>
</div>
{% if student_cards_module_enabled %}
<div class="form-group">
<label style="display:flex; align-items:center; gap:8px; color:#1f2937;">
<input type="checkbox" id="is-student" name="is_student" style="width:auto;">
Schülerkonto mit Ausweis
</label>
<div id="student-fields" style="display:none; margin-top:10px;">
<label for="student-card-id">Ausweis-ID</label>
<div class="input-container">
<span class="input-icon">🪪</span>
<input type="text" id="student-card-id" name="student_card_id" placeholder="z.B. S-2026-001">
</div>
<label for="max-borrow-days" style="margin-top:10px;">Standard-Ausleihdauer (Tage)</label>
<div class="input-container">
<span class="input-icon">📅</span>
<input type="number" id="max-borrow-days" name="max_borrow_days" min="1" max="{{ student_max_borrow_days }}" value="{{ student_default_borrow_days }}">
</div>
</div>
</div>
{% endif %}
<div class="form-group form-actions">
<button type="submit" class="action-button register-button">Benutzer registrieren</button>
</div>
</form>
</div>
</div>
</div>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--error-color: #e74c3c;
--text-color: #333;
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.1rem;
color: #777;
margin-top: 0;
}
.content {
background-color: #fff;
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.form-card {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--primary-dark);
}
.input-container {
position: relative;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #aaa;
font-size: 1.2rem;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 0.8rem 1rem 0.8rem 3rem;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 1rem;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
input::placeholder {
color: #aaa;
}
.form-actions {
margin-top: 2rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 30px;
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: #27ae60;
}
.navigation-buttons {
text-align: center;
margin: 1.5rem 0;
}
.back-button {
display: inline-flex;
align-items: center;
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
transition: var(--transition);
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
}
.back-button:hover {
background-color: rgba(52, 152, 219, 0.1);
}
.back-icon {
margin-right: 0.5rem;
font-size: 1.2rem;
}
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
}
.flash.success {
background-color: #d4edda;
color: #155724;
border-left: 4px solid var(--success-color);
}
.flash.error {
background-color: #f8d7da;
color: #721c24;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
margin: 1rem auto;
}
.content {
padding: 1.5rem;
}
.header-section h1 {
font-size: 2rem;
}
}
.richtlinen{
color: #ec0920;
}
</style>
{% if student_cards_module_enabled %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const studentCheckbox = document.getElementById('is-student');
const studentFields = document.getElementById('student-fields');
const studentCardInput = document.getElementById('student-card-id');
if (!studentCheckbox || !studentFields || !studentCardInput) {
return;
}
function toggleStudentFields() {
const isChecked = studentCheckbox.checked;
studentFields.style.display = isChecked ? 'block' : 'none';
studentCardInput.required = isChecked;
}
studentCheckbox.addEventListener('change', toggleStudentFields);
toggleStudentFields();
});
</script>
{% endif %}
{% endblock %}
+307
View File
@@ -0,0 +1,307 @@
<!--
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
-->
<!--
Reset Item Functions
====================
This template contains JavaScript functions for resetting item borrowing status.
It handles cleaning up inconsistent states between items and their borrowing records.
-->
<script>
/**
* Reset an item's borrowing status completely
*/
function resetItemBorrowingStatus(itemId) {
// Show confirmation dialog
const confirmMessage = `Sind Sie sicher, dass Sie den Ausleihstatus dieses Items zurücksetzen möchten?\n\n` +
`Dies wird:\n` +
`- Das Item als verfügbar markieren\n` +
`- Alle aktiven Ausleihungen beenden\n` +
`- Den Ausleihstatus vollständig zurücksetzen\n\n` +
`Diese Aktion kann nicht rückgängig gemacht werden.`;
if (!confirm(confirmMessage)) {
return;
}
// Show loading indicator
const loadingDiv = createLoadingIndicator('Item wird zurückgesetzt...');
document.body.appendChild(loadingDiv);
// Send reset request to server
fetch(`/reset_item/${itemId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'reset_type': 'complete',
'timestamp': new Date().toISOString()
})
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || data.error || `HTTP error! status: ${response.status}`);
});
}
return response.json();
})
.then(data => {
document.body.removeChild(loadingDiv);
if (data.success) {
showSuccessMessage('Item-Status wurde erfolgreich zurückgesetzt!');
// Reload the page to reflect changes
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
throw new Error(data.message || 'Unbekannter Fehler beim Zurücksetzen');
}
})
.catch(error => {
if (loadingDiv.parentNode) {
document.body.removeChild(loadingDiv);
}
console.error('Error resetting item:', error);
showErrorMessage(`Fehler beim Zurücksetzen des Items: ${error.message}`);
});
}
/**
* Create a loading indicator element
*/
function createLoadingIndicator(message) {
const loadingDiv = document.createElement('div');
loadingDiv.className = 'reset-loading-overlay';
loadingDiv.innerHTML = `
<div class="reset-loading-content">
<div class="reset-loading-spinner"></div>
<div class="reset-loading-message">${message}</div>
</div>
`;
// Add styles if not already present
if (!document.getElementById('reset-styles')) {
const style = document.createElement('style');
style.id = 'reset-styles';
style.textContent = `
.reset-loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
}
.reset-loading-content {
background: white;
padding: 30px;
border-radius: 8px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 90%;
}
.reset-loading-spinner {
width: 40px;
height: 40px;
margin: 0 auto 20px;
border: 4px solid #f3f3f3;
border-top: 4px solid #dc3545;
border-radius: 50%;
animation: reset-spin 1s linear infinite;
}
.reset-loading-message {
color: #333;
font-size: 16px;
font-weight: 500;
}
@keyframes reset-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.reset-success-message {
position: fixed;
top: 20px;
right: 20px;
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
border-radius: 5px;
padding: 15px 20px;
z-index: 10002;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
font-weight: 500;
max-width: 400px;
animation: reset-slide-in 0.3s ease, reset-fade-out 0.5s ease 2.5s forwards;
}
.reset-error-message {
position: fixed;
top: 20px;
right: 20px;
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 5px;
padding: 15px 40px 15px 20px;
z-index: 10002;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
max-width: 400px;
animation: reset-slide-in 0.3s ease;
position: relative;
}
.reset-error-content {
font-weight: 500;
line-height: 1.4;
}
.reset-error-close {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 24px;
color: #721c24;
cursor: pointer;
padding: 0;
line-height: 1;
}
.reset-error-close:hover {
color: #491217;
}
@keyframes reset-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes reset-fade-out {
to {
opacity: 0;
transform: translateX(100%);
}
}
`;
document.head.appendChild(style);
}
return loadingDiv;
}
/**
* Show a success message to the user
*/
function showSuccessMessage(message) {
const successDiv = document.createElement('div');
successDiv.className = 'reset-success-message';
successDiv.textContent = message;
document.body.appendChild(successDiv);
// Remove after 3 seconds
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
}, 3000);
}
/**
* Show an error message to the user
*/
function showErrorMessage(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'reset-error-message';
errorDiv.innerHTML = `
<div class="reset-error-content">
<strong>⚠️ Fehler</strong><br>
${message}
</div>
<button class="reset-error-close" onclick="this.parentElement.remove()">×</button>
`;
document.body.appendChild(errorDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
}
/**
* Check if an item has borrowing issues that would warrant a reset
*/
function checkForBorrowingIssues(item) {
// Check for inconsistent availability status
if (!item.Verfuegbar && !item.User) {
return true; // Item marked as unavailable but no user assigned
}
// Check for exemplar status issues
if (item.ExemplareStatus && Array.isArray(item.ExemplareStatus)) {
const totalExemplare = item.Exemplare || 1;
const borrowedCount = item.ExemplareStatus.length;
if (borrowedCount > totalExemplare) {
return true; // More exemplars borrowed than exist
}
if (borrowedCount > 0 && item.Verfuegbar) {
return true; // Has borrowed exemplars but marked as available
}
}
// Check for stale borrower information
if (!item.Verfuegbar && item.BorrowerInfo) {
const borrowTime = item.BorrowerInfo.borrowTime;
if (borrowTime) {
try {
const borrowDate = new Date(borrowTime);
const now = new Date();
const daysDiff = (now - borrowDate) / (1000 * 60 * 60 * 24);
// If borrowed for more than 30 days, might be stale
if (daysDiff > 30) {
return true;
}
} catch (e) {
// Invalid date format
return true;
}
}
}
return false;
}
</script>
+816
View File
@@ -0,0 +1,816 @@
<!--
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
-->
<!--
Reset Item Modal
================
This template contains a dedicated modal window for resetting item borrowing status.
It provides detailed information about what will be reset and requires confirmation.
-->
<!-- Reset Item Modal -->
<div id="reset-item-modal" class="reset-modal-overlay" style="display: none;">
<div class="reset-modal-container">
<div class="reset-modal-header">
<h2>
<i class="reset-icon">⚠️</i>
Item-Status zurücksetzen
</h2>
<button class="reset-modal-close" onclick="closeResetModal()">&times;</button>
</div>
<div class="reset-modal-content">
<div class="reset-item-info">
<h3 id="reset-item-name">Item wird geladen...</h3>
<div class="reset-item-details">
<p><strong>ID:</strong> <span id="reset-item-id">-</span></p>
<p><strong>Ort:</strong> <span id="reset-item-location">-</span></p>
<p><strong>Aktueller Status:</strong> <span id="reset-item-status">-</span></p>
<p><strong>Aktuelle Ausleiher:</strong> <span id="reset-item-borrower">-</span></p>
</div>
</div>
<div class="reset-warning-section">
<div class="reset-warning-box">
<h4>⚠️ Achtung: Diese Aktion kann nicht rückgängig gemacht werden!</h4>
<p>Das Zurücksetzen des Item-Status wird folgende Aktionen durchführen:</p>
</div>
<div class="reset-actions-list">
<div class="reset-action-item">
<input type="checkbox" id="reset-availability" checked disabled>
<label for="reset-availability">
<strong>Item als verfügbar markieren</strong>
<span class="reset-action-desc">Das Item wird als "Verfügbar" gesetzt</span>
</label>
</div>
<div class="reset-action-item">
<input type="checkbox" id="reset-borrower" checked disabled>
<label for="reset-borrower">
<strong>Ausleiher-Informationen entfernen</strong>
<span class="reset-action-desc">Alle Benutzer-Zuordnungen werden gelöscht</span>
</label>
</div>
<div class="reset-action-item" id="reset-exemplar-section" style="display: none;">
<input type="checkbox" id="reset-exemplars" checked disabled>
<label for="reset-exemplars">
<strong>Exemplar-Status zurücksetzen</strong>
<span class="reset-action-desc">Alle Exemplar-Ausleihungen werden beendet</span>
</label>
</div>
<div class="reset-action-item" id="reset-borrowings-section">
<input type="checkbox" id="reset-borrowings" checked disabled>
<label for="reset-borrowings">
<strong>Aktive Ausleihungen beenden</strong>
<span class="reset-action-desc">Alle aktiven/geplanten Ausleihungen werden als "abgeschlossen" markiert</span>
</label>
<div id="active-borrowings-list" class="active-borrowings"></div>
</div>
</div>
</div>
<div class="reset-options-section">
<h4>Reset-Optionen:</h4>
<div class="reset-option">
<input type="checkbox" id="reset-create-log" checked>
<label for="reset-create-log">Log-Eintrag für diese Aktion erstellen</label>
</div>
<div class="reset-option">
<input type="checkbox" id="reset-notify-users">
<label for="reset-notify-users">Betroffene Benutzer benachrichtigen (falls möglich)</label>
</div>
</div>
<div class="reset-confirmation-section">
<div class="reset-confirmation-box">
<input type="checkbox" id="reset-final-confirmation">
<label for="reset-final-confirmation">
<strong>Ich bestätige, dass ich dieses Item zurücksetzen möchte</strong>
</label>
</div>
<div class="reset-reason-section">
<label for="reset-reason">Grund für das Zurücksetzen (optional):</label>
<textarea id="reset-reason" placeholder="z.B. Inkonsistenter Status, Item fälschlicherweise als ausgeliehen markiert, etc."></textarea>
</div>
</div>
</div>
<div class="reset-modal-footer">
<button class="reset-cancel-btn" onclick="closeResetModal()">
Abbrechen
</button>
<button class="reset-confirm-btn" id="reset-execute-btn" onclick="executeItemReset()" disabled>
<i class="reset-execute-icon">🔄</i>
Item zurücksetzen
</button>
</div>
</div>
</div>
<style>
/* Reset Modal Styles */
.reset-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
animation: resetModalFadeIn 0.3s ease;
}
@keyframes resetModalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.reset-modal-container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 700px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
animation: resetModalSlideIn 0.4s ease;
}
@keyframes resetModalSlideIn {
from {
transform: translateY(-50px) scale(0.9);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.reset-modal-header {
background: linear-gradient(135deg, #dc3545, #c82333);
color: white;
padding: 20px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.reset-modal-header h2 {
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
gap: 10px;
}
.reset-icon {
font-size: 1.6rem;
}
.reset-modal-close {
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.reset-modal-close:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.reset-modal-content {
padding: 25px;
}
.reset-item-info {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
border-left: 4px solid #007bff;
}
.reset-item-info h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 1.3rem;
}
.reset-item-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.reset-item-details p {
margin: 5px 0;
color: #666;
}
.reset-item-details strong {
color: #333;
}
.reset-warning-section {
margin-bottom: 25px;
}
.reset-warning-box {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.reset-warning-box h4 {
margin: 0 0 10px 0;
color: #856404;
font-size: 1.1rem;
}
.reset-warning-box p {
margin: 0;
color: #856404;
}
.reset-actions-list {
space-y: 15px;
}
.reset-action-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
transition: border-color 0.3s ease;
}
.reset-action-item:hover {
border-color: #007bff;
}
.reset-action-item input[type="checkbox"] {
margin-right: 12px;
transform: scale(1.2);
}
.reset-action-item label {
display: flex;
flex-direction: column;
cursor: pointer;
font-weight: 500;
color: #333;
}
.reset-action-desc {
font-weight: normal;
color: #666;
font-size: 0.9rem;
margin-top: 5px;
}
.active-borrowings {
margin-top: 10px;
padding: 10px;
background: #f8f9fa;
border-radius: 5px;
font-size: 0.9rem;
}
.borrowing-entry {
padding: 5px 0;
border-bottom: 1px solid #dee2e6;
}
.borrowing-entry:last-child {
border-bottom: none;
}
.reset-options-section {
background: #f1f3f4;
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
}
.reset-options-section h4 {
margin: 0 0 15px 0;
color: #333;
}
.reset-option {
margin-bottom: 10px;
}
.reset-option input[type="checkbox"] {
margin-right: 10px;
transform: scale(1.1);
}
.reset-option label {
color: #555;
font-weight: 500;
cursor: pointer;
}
.reset-confirmation-section {
border: 2px solid #dc3545;
border-radius: 8px;
padding: 20px;
background: #fdf2f2;
}
.reset-confirmation-box {
margin-bottom: 15px;
}
.reset-confirmation-box input[type="checkbox"] {
margin-right: 10px;
transform: scale(1.2);
}
.reset-confirmation-box label {
font-weight: bold;
color: #721c24;
cursor: pointer;
}
.reset-reason-section label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.reset-reason-section textarea {
width: 100%;
height: 80px;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 5px;
font-family: inherit;
resize: vertical;
background: white;
}
.reset-reason-section textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 5px rgba(0, 123, 255, 0.3);
}
.reset-modal-footer {
padding: 20px 25px;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 15px;
background: #f8f9fa;
border-radius: 0 0 12px 12px;
}
.reset-cancel-btn {
background: #6c757d;
color: white;
border: none;
padding: 12px 25px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
}
.reset-cancel-btn:hover {
background: #5a6268;
transform: translateY(-1px);
}
.reset-confirm-btn {
background: #dc3545;
color: white;
border: none;
padding: 12px 25px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.reset-confirm-btn:hover:not(:disabled) {
background: #c82333;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
}
.reset-confirm-btn:disabled {
background: #cccccc;
cursor: not-allowed;
transform: none;
}
.reset-execute-icon {
font-size: 1rem;
}
/* Loading states */
.reset-loading .reset-execute-icon {
animation: resetSpin 1s linear infinite;
}
@keyframes resetSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Responsive adjustments */
@media (max-width: 768px) {
.reset-modal-container {
width: 95%;
margin: 10px;
max-height: 95vh;
}
.reset-modal-content {
padding: 20px;
}
.reset-item-details {
grid-template-columns: 1fr;
}
.reset-modal-footer {
flex-direction: column;
}
.reset-cancel-btn,
.reset-confirm-btn {
width: 100%;
justify-content: center;
}
}
/* Success animation */
.reset-success {
animation: resetSuccess 0.6s ease;
}
@keyframes resetSuccess {
0% { transform: scale(1); }
50% { transform: scale(1.05); background-color: #28a745; }
100% { transform: scale(1); }
}
</style>
<script>
// Global variable to store the current item being reset
let currentResetItem = null;
/**
* Opens the reset modal for a specific item
*/
function openResetModal(itemId) {
currentResetItem = itemId;
// Show the modal
document.getElementById('reset-item-modal').style.display = 'flex';
// Load item details
loadItemDetailsForReset(itemId);
// Reset form state
resetModalFormState();
// Add event listeners
setupResetModalEventListeners();
}
/**
* Closes the reset modal
*/
function closeResetModal() {
document.getElementById('reset-item-modal').style.display = 'none';
currentResetItem = null;
resetModalFormState();
}
/**
* Loads item details for the reset modal
*/
function loadItemDetailsForReset(itemId) {
// Show loading state
document.getElementById('reset-item-name').textContent = 'Lade Item-Details...';
document.getElementById('reset-item-id').textContent = itemId;
document.getElementById('reset-item-location').textContent = 'Wird geladen...';
document.getElementById('reset-item-status').textContent = 'Wird geladen...';
document.getElementById('reset-item-borrower').textContent = 'Wird geladen...';
// Fetch item details
fetch(`/get_item/${itemId}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success' && data.item) {
const item = data.item;
// Update item information
document.getElementById('reset-item-name').textContent = item.Name || 'Unbekanntes Item';
document.getElementById('reset-item-location').textContent = item.Ort || 'Kein Ort angegeben';
// Determine status
let statusText = 'Verfügbar';
let borrowerText = 'Nicht ausgeliehen';
if (!item.Verfuegbar) {
statusText = 'Ausgeliehen';
borrowerText = item.User || 'Unbekannter Benutzer';
} else if (item.ExemplareStatus && item.ExemplareStatus.length > 0) {
statusText = `${item.ExemplareStatus.length} Exemplar(e) ausgeliehen`;
const borrowers = item.ExemplareStatus.map(ex => ex.user).join(', ');
borrowerText = borrowers;
} else if (item.BorrowerInfo) {
statusText = 'Möglicherweise ausgeliehen (inkonsistent)';
borrowerText = item.BorrowerInfo.username || 'Unbekannt';
}
document.getElementById('reset-item-status').textContent = statusText;
document.getElementById('reset-item-borrower').textContent = borrowerText;
// Show/hide exemplar section
const exemplarSection = document.getElementById('reset-exemplar-section');
if (item.ExemplareStatus && item.ExemplareStatus.length > 0) {
exemplarSection.style.display = 'block';
} else {
exemplarSection.style.display = 'none';
}
// Load active borrowings
loadActiveBorrowingsForReset(itemId);
} else {
document.getElementById('reset-item-name').textContent = 'Fehler beim Laden';
document.getElementById('reset-item-status').textContent = 'Unbekannt';
document.getElementById('reset-item-borrower').textContent = 'Unbekannt';
}
})
.catch(error => {
console.error('Error loading item details:', error);
document.getElementById('reset-item-name').textContent = 'Fehler beim Laden';
});
}
/**
* Loads active borrowings for the item
*/
function loadActiveBorrowingsForReset(itemId) {
fetch(`/get_ausleihung_by_item/${itemId}`)
.then(response => response.json())
.then(data => {
const borrowingsList = document.getElementById('active-borrowings-list');
if (data.status === 'success' && data.ausleihung) {
const borrowing = data.ausleihung;
borrowingsList.innerHTML = `
<div class="borrowing-entry">
<strong>Benutzer:</strong> ${borrowing.User || 'Unbekannt'}<br>
<strong>Start:</strong> ${borrowing.Start ? new Date(borrowing.Start).toLocaleString('de-DE') : 'Unbekannt'}<br>
<strong>Status:</strong> ${borrowing.Status || 'Unbekannt'}
</div>
`;
} else {
borrowingsList.innerHTML = '<em>Keine aktiven Ausleihungen gefunden</em>';
}
})
.catch(error => {
console.error('Error loading borrowings:', error);
document.getElementById('active-borrowings-list').innerHTML = '<em>Fehler beim Laden der Ausleihungen</em>';
});
}
/**
* Resets the modal form state
*/
function resetModalFormState() {
// Reset checkboxes
document.getElementById('reset-create-log').checked = true;
document.getElementById('reset-notify-users').checked = false;
document.getElementById('reset-final-confirmation').checked = false;
// Clear reason textarea
document.getElementById('reset-reason').value = '';
// Disable execute button
document.getElementById('reset-execute-btn').disabled = true;
}
/**
* Sets up event listeners for the reset modal
*/
function setupResetModalEventListeners() {
// Confirmation checkbox listener
const confirmationCheckbox = document.getElementById('reset-final-confirmation');
const executeButton = document.getElementById('reset-execute-btn');
confirmationCheckbox.addEventListener('change', function() {
executeButton.disabled = !this.checked;
});
// ESC key to close modal
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && document.getElementById('reset-item-modal').style.display === 'flex') {
closeResetModal();
}
});
// Click outside modal to close
document.getElementById('reset-item-modal').addEventListener('click', function(event) {
if (event.target === this) {
closeResetModal();
}
});
}
/**
* Executes the item reset
*/
function executeItemReset() {
if (!currentResetItem) {
alert('Kein Item ausgewählt');
return;
}
const executeButton = document.getElementById('reset-execute-btn');
const originalText = executeButton.innerHTML;
// Show loading state
executeButton.classList.add('reset-loading');
executeButton.disabled = true;
executeButton.innerHTML = '<i class="reset-execute-icon">🔄</i> Wird zurückgesetzt...';
// Collect form data
const resetData = {
item_id: currentResetItem,
create_log: document.getElementById('reset-create-log').checked,
notify_users: document.getElementById('reset-notify-users').checked,
reason: document.getElementById('reset-reason').value.trim() || 'Item-Status Reset über Admin-Interface',
timestamp: new Date().toISOString()
};
// Send reset request
fetch(`/reset_item/${currentResetItem}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(resetData)
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || data.error || `HTTP error! status: ${response.status}`);
});
}
return response.json();
})
.then(data => {
executeButton.classList.remove('reset-loading');
if (data.success) {
// Show success state
executeButton.classList.add('reset-success');
executeButton.innerHTML = '<i class="reset-execute-icon">✅</i> Erfolgreich zurückgesetzt!';
// Show success message
showResetSuccessMessage(data.message);
// Close modal after delay
setTimeout(() => {
closeResetModal();
// Reload the page to reflect changes
window.location.reload();
}, 2000);
} else {
throw new Error(data.message || 'Unbekannter Fehler beim Zurücksetzen');
}
})
.catch(error => {
executeButton.classList.remove('reset-loading');
executeButton.disabled = false;
executeButton.innerHTML = originalText;
console.error('Error resetting item:', error);
showResetErrorMessage(`Fehler beim Zurücksetzen: ${error.message}`);
});
}
/**
* Shows a success message
*/
function showResetSuccessMessage(message) {
const successDiv = document.createElement('div');
successDiv.className = 'reset-success-message';
successDiv.innerHTML = `
<strong>✅ Erfolg!</strong><br>
${message}
`;
successDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
border-radius: 8px;
padding: 15px 20px;
z-index: 10003;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-weight: 500;
max-width: 400px;
animation: resetSlideIn 0.3s ease, resetFadeOut 0.5s ease 3s forwards;
`;
document.body.appendChild(successDiv);
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
}, 3500);
}
/**
* Shows an error message
*/
function showResetErrorMessage(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'reset-error-message';
errorDiv.innerHTML = `
<strong>⚠️ Fehler</strong><br>
${message}
<button onclick="this.parentElement.remove()" style="
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 20px;
color: #721c24;
cursor: pointer;
">×</button>
`;
errorDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 8px;
padding: 15px 40px 15px 20px;
z-index: 10003;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 400px;
animation: resetSlideIn 0.3s ease;
position: relative;
`;
document.body.appendChild(errorDiv);
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
}
// Update the original resetItemBorrowingStatus function to use the new modal
function resetItemBorrowingStatus(itemId) {
openResetModal(itemId);
}
</script>
@@ -0,0 +1,156 @@
<!--
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
-->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schülerausweis - Barcode PDF Download</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
text-align: center;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
max-width: 500px;
width: 100%;
}
.icon {
font-size: 60px;
margin-bottom: 20px;
}
h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
}
p {
color: #666;
font-size: 14px;
margin-bottom: 30px;
line-height: 1.6;
}
.info-box {
background: #f0f0f0;
border-left: 4px solid #667eea;
padding: 15px;
margin-bottom: 30px;
text-align: left;
border-radius: 4px;
}
.info-box strong {
color: #333;
}
.info-box p {
margin: 5px 0;
font-size: 13px;
}
.btn-group {
display: flex;
gap: 12px;
flex-direction: column;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.status {
font-size: 12px;
color: #999;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📇</div>
<h1>Schülerausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p>
<div class="info-box">
<strong>📌 Was wird heruntergeladen?</strong>
<p>Eine druckfertige PDF mit:</p>
<p>✓ Alle Schülerausweise</p>
<p>✓ Scanbare CODE128 Barcodes</p>
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
<p>✓ Professionelle Qualität</p>
</div>
<div class="btn-group">
<a href="{{ download_link }}" class="btn btn-primary">
📥 PDF herunterladen & Drucken
</a>
<button onclick="window.history.back()" class="btn btn-secondary">
Zurück
</button>
</div>
<div class="status">
<p>Generiert am: <span id="timestamp"></span></p>
</div>
</div>
<script>
document.getElementById('timestamp').textContent = new Date().toLocaleString('de-DE');
</script>
</body>
</html>
+358
View File
@@ -0,0 +1,358 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Schülerausweise - Inventarsystem{% endblock %}
{% block content %}
<style>
.student-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.student-card-form {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-weight: 600;
margin-bottom: 5px;
color: #333;
}
.form-group input,
.form-group select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 5px rgba(0, 123, 255, 0.3);
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.form-actions button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.btn-save {
background: #28a745;
color: white;
}
.btn-save:hover {
background: #218838;
}
.btn-cancel {
background: #6c757d;
color: white;
}
.btn-cancel:hover {
background: #5a6268;
}
.cards-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.cards-table thead {
background: #007bff;
color: white;
font-weight: 600;
}
.cards-table th,
.cards-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.cards-table tbody tr:hover {
background: #f9f9f9;
}
.cards-table tbody tr:last-child td {
border-bottom: none;
}
.card-actions {
display: flex;
gap: 8px;
}
.btn-edit, .btn-delete {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.btn-edit {
background: #007bff;
color: white;
}
.btn-edit:hover {
background: #0056b3;
}
.btn-delete {
background: #dc3545;
color: white;
}
.btn-delete:hover {
background: #c82333;
}
.btn-print {
padding: 10px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
text-decoration: none;
display: inline-block;
}
.btn-print {
background: #17a2b8;
color: white;
}
.btn-print:hover {
background: #138496;
}
.btn-export {
background: #6f42c1;
color: white;
}
.btn-export:hover {
background: #5a32a3;
}
.export-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.empty-state {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state p {
font-size: 16px;
margin: 10px 0;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
.student-card-header {
flex-direction: column;
align-items: flex-start;
}
.cards-table {
font-size: 14px;
}
.cards-table th,
.cards-table td {
padding: 8px 10px;
}
.card-actions {
flex-direction: column;
}
}
</style>
<div class="container">
<div class="student-card-header">
<div>
<h1>📚 Schülerausweise (Bibliotek)</h1>
</div>
<div class="export-buttons">
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
<a href="{{ url_for('library_admin') }}" class="btn btn-primary">← Zur Bibliotheks-Upload</a>
</div>
</div>
<!-- Add/Edit Form -->
<div class="student-card-form">
<h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Schülerausweis{% endif %}</h2>
<form method="POST" action="{{ url_for('student_cards_admin') }}">
{% if edit_mode %}
<input type="hidden" name="action" value="edit">
<input type="hidden" name="card_id" value="{{ form_data.get('card_id', '') }}">
{% else %}
<input type="hidden" name="action" value="add">
{% endif %}
<div class="form-row">
<div class="form-group">
<label for="ausweis_id">Ausweis-ID *</label>
<input type="text" id="ausweis_id" name="ausweis_id" required
value="{{ form_data.get('ausweis_id', '') }}"
placeholder="z.B. SIS2024001">
</div>
<div class="form-group">
<label for="student_name">Schüler Name *</label>
<input type="text" id="student_name" name="student_name" required
value="{{ form_data.get('student_name', '') }}"
placeholder="z.B. Max Mustermann">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="default_borrow_days">Standard Ausleihdauer (Tage) *</label>
<input type="number" id="default_borrow_days" name="default_borrow_days"
min="1" max="365" required
value="{{ form_data.get('default_borrow_days', config.get('default', 14)) }}">
</div>
<div class="form-group">
<label for="class_name">Klasse</label>
<input type="text" id="class_name" name="class_name"
value="{{ form_data.get('class_name', '') }}"
placeholder="z.B. 10A">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="notes">Notizen</label>
<textarea id="notes" name="notes" rows="3"
placeholder="Optionale Notizen..."
style="padding: 10px; border: 1px solid #ddd; border-radius: 4px;">{{ form_data.get('notes', '') }}</textarea>
</div>
</div>
<div class="form-actions">
{% if edit_mode %}
<a href="{{ url_for('student_cards_admin') }}" class="btn-cancel">Abbrechen</a>
{% endif %}
<button type="submit" class="btn-save">
{% if edit_mode %}Speichern{% else %}Hinzufügen{% endif %}
</button>
</div>
</form>
</div>
<!-- Cards List -->
<div>
<h2>Registrierte Ausweise</h2>
{% if student_cards %}
<table class="cards-table">
<thead>
<tr>
<th>Ausweis-ID</th>
<th>Schüler Name</th>
<th>Klasse</th>
<th>Standard Ausleihdauer</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for card in student_cards %}
<tr>
<td><strong>{{ card.AusweisId }}</strong></td>
<td>{{ card.SchülerName }}</td>
<td>{{ card.Klasse or '—' }}</td>
<td>{{ card.StandardAusleihdauer }} Tage</td>
<td>{{ card.Erstellt.strftime('%d.%m.%Y') if card.get('Erstellt') else '—' }}</td>
<td>
<div class="card-actions">
<form method="GET" style="display: inline;">
<input type="hidden" name="edit" value="{{ card._id }}">
<button type="submit" class="btn-edit">Bearbeiten</button>
</form>
<a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export" style="text-decoration: none; display: inline-block;">📥 PDF</a>
<form method="POST" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="card_id" value="{{ card._id }}">
<button type="submit" class="btn-delete">Löschen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Keine Schülerausweise registriert.</p>
<p>Fügen Sie ein neues Ausweis oben hinzu.</p>
</div>
{% endif %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode.js"></script>
<script>
// All PDF exports now go through backend routes
</script>
{% endblock %}
+1629
View File
File diff suppressed because it is too large Load Diff
+445
View File
@@ -0,0 +1,445 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Tutorial - {{ APP_VERSION }}{% endblock %}
{% block content %}
<style>
.tutorial-shell {
max-width: 1120px;
margin: 0 auto;
padding: 24px 0 14px;
}
.tutorial-shell,
.tutorial-shell p,
.tutorial-shell li,
.tutorial-shell button,
.tutorial-shell a {
font-size: 1.03rem;
line-height: 1.55;
}
.tutorial-hero {
background: linear-gradient(135deg, #16324f 0%, #27455f 55%, #3c5a73 100%);
color: #f8fafc;
border-radius: 18px;
padding: 30px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
margin-bottom: 18px;
}
.tutorial-hero h1 {
margin: 0 0 8px;
font-size: clamp(1.8rem, 3vw, 2.35rem);
font-weight: 900;
}
.tutorial-hero p {
margin: 0;
opacity: 0.98;
font-size: 1.08rem;
}
.tutorial-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.tutorial-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
padding: 6px 14px;
font-size: 0.88rem;
font-weight: 700;
}
.tutorial-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
}
.tutorial-side {
grid-column: span 12;
background: #ffffff;
border: 1px solid #cfdbe8;
border-radius: 16px;
padding: 16px;
position: sticky;
top: 14px;
height: fit-content;
}
.tutorial-side h2 {
margin: 0 0 8px;
font-size: 1.08rem;
color: #0f172a;
font-weight: 800;
}
.tutorial-side p {
margin: 0 0 14px;
color: #334155;
font-size: 1rem;
}
.workflow-nav {
display: grid;
gap: 7px;
margin-top: 10px;
}
.workflow-nav button {
border: 1px solid #dbe3ee;
background: #f8fafc;
color: #0f172a;
border-radius: 12px;
padding: 11px 12px;
text-align: left;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: all 180ms ease;
}
.workflow-nav button:hover {
background: #eff6ff;
border-color: #bfdbfe;
transform: translateX(2px);
}
.workflow-nav button.active {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.workflow-main {
grid-column: span 12;
display: grid;
gap: 12px;
}
.workflow-step {
grid-column: span 12;
background: #ffffff;
border: 1px solid #cfdbe8;
border-radius: 16px;
padding: 18px;
opacity: 0;
transform: translateY(8px) scale(0.995);
pointer-events: none;
max-height: 0;
overflow: hidden;
transition: opacity 220ms ease, transform 220ms ease;
}
.workflow-step.active {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
max-height: 1300px;
}
.workflow-step h3 {
margin: 0 0 8px;
font-size: 1.35rem;
color: #0f172a;
}
.workflow-step p {
margin: 0 0 10px;
color: #334155;
font-size: 1.03rem;
}
.workflow-step ul {
margin: 0;
padding-left: 18px;
color: #334155;
font-size: 1.02rem;
}
.workflow-step li {
margin-bottom: 5px;
}
.tutorial-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tutorial-actions .btn,
.workflow-controls .btn {
border-radius: 12px;
padding: 10px 14px;
font-size: 1rem;
}
.workflow-controls {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
.workflow-controls .btn {
min-width: 120px;
}
.tutorial-note {
margin-top: 14px;
background: #ecfeff;
border: 1px solid #bae6fd;
color: #0c4a6e;
border-radius: 14px;
padding: 14px;
font-size: 1rem;
}
@media (min-width: 960px) {
.tutorial-side {
grid-column: span 4;
}
.workflow-main {
grid-column: span 8;
}
}
</style>
<div class="tutorial-shell">
<section class="tutorial-hero">
<h1>Kurzer Rundgang in 2-5 Minuten</h1>
<p>Die Seite ist bewusst einfach gehalten. Sie begleitet Sie Schritt für Schritt, ohne viel Lesen und ohne komplizierte Fachbegriffe.</p>
<div class="tutorial-meta">
<span class="tutorial-pill">Dauer: ca. 4 Minuten</span>
<span class="tutorial-pill">Format: Ruhig und geführt</span>
<span class="tutorial-pill">Benutzer: {{ username }}</span>
</div>
</section>
<div class="tutorial-grid">
<aside class="tutorial-side">
<h2>Schritte</h2>
<p>Folgen Sie einfach jedem Schritt. Sie können jederzeit vor- oder zurückgehen.</p>
<div class="workflow-nav" id="workflowNav">
<button type="button" data-target-step="0">1. Startseite</button>
<button type="button" data-target-step="1">2. {{ 'Artikel erfassen' if is_admin else 'Artikel finden' }}</button>
{% if library_module_enabled %}
<button type="button" data-target-step="2">3. Bibliotek</button>
{% endif %}
<button type="button" data-target-step="{{ '3' if library_module_enabled else '2' }}">4. Alltag</button>
</div>
<div class="workflow-controls">
<button type="button" class="btn btn-outline-secondary btn-sm" id="prevStepBtn">Zurück</button>
<button type="button" class="btn btn-primary btn-sm" id="nextStepBtn">Nächster Schritt</button>
</div>
</aside>
<section class="workflow-main" id="workflowMain">
<article class="workflow-step" data-step-index="0" data-step-key="dashboard">
<h3>Startbereich</h3>
<p>Hier starten Sie. Das ist der sichere Ausgangspunkt für alle weiteren Schritte.</p>
<ul>
<li>Sie sehen den Überblick.</li>
<li>Oben liegen die wichtigsten Wege.</li>
<li>Alles ist gross und einfach lesbar.</li>
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('home') }}">Startseite öffnen</a>
</div>
</article>
<article class="workflow-step" data-step-index="1" data-step-key="items">
{% if is_admin %}
<h3>Artikel anlegen</h3>
<p>Hier legen Sie einen neuen Artikel an. Es sind nur wenige Felder erforderlich.</p>
<ul>
<li><strong>Name:</strong> Wie heisst der Artikel?</li>
<li><strong>Ort:</strong> Wo soll er gelagert werden?</li>
<li><strong>Beschreibung:</strong> Was ist daran interessant?</li>
<li><strong>Code:</strong> Eine Nummer zum schnellen Finden.</li>
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('upload_admin') }}">Neuen Artikel anlegen</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('home_admin') }}">Zurück</a>
</div>
{% else %}
<h3>Artikel finden und ausleihen</h3>
<p>Als Nutzerin/Nutzer arbeiten Sie hauptsaechlich mit vorhandenen Artikeln und Ausleihen.</p>
<ul>
<li>Nutzen Sie Filter und Suche, um Material schnell zu finden.</li>
<li>Öffnen Sie <strong>Details</strong>, um Ort, Beschreibung und Verfügbarkeit zu sehen.</li>
<li>Mit <strong>Ausleihen</strong> buchen Sie den Artikel direkt.</li>
<li>In <strong>Meine Ausleihen</strong> sehen Sie, was aktuell auf Ihren Namen laeuft.</li>
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('home') }}">Zur Startseite</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
</div>
{% endif %}
</article>
{% if library_module_enabled %}
<article class="workflow-step" data-step-index="2" data-step-key="library">
<h3>Bibliotek-Bereich</h3>
<p>Dieser Bereich ist speziell für Bücher und Medienausleihe.</p>
<ul>
{% if is_admin %}
<li><strong>Bücher hochladen:</strong> Neue Bücher in die Sammlung aufnehmen.</li>
<li><strong>Ausleihen ansehen:</strong> Wer hat welches Buch ausgeliehen?</li>
<li><strong>Defekte bearbeiten:</strong> Defekte Bücher markieren oder reparieren.</li>
<li><strong>Rechnungen:</strong> Kosten und Rechnungen verwalten.</li>
{% else %}
<li><strong>Medium suchen:</strong> Titel, Autor oder Barcode direkt finden.</li>
<li><strong>Verfuegbarkeit pruefen:</strong> Schnell sehen, was aktuell ausleihbar ist.</li>
<li><strong>Ausleihen:</strong> In der Bibliothek direkt auf Ihren Namen buchen.</li>
<li><strong>Rückgabe-Status:</strong> Über "Meine Ausleihen" Fristen im Blick behalten.</li>
{% endif %}
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('library_view') }}">Zur Bibliotek</a>
{% if is_admin %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_loans_admin') }}">Ausleihen ansehen</a>
{% endif %}
</div>
</article>
{% endif %}
<article class="workflow-step" data-step-index="{{ '3' if library_module_enabled else '2' }}" data-step-key="daily">
<h3>Tägliche Aufgaben</h3>
<p>Das ist der gewöhnliche Arbeitsablauf während eines Schultages.</p>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Morgens:</p>
<p style="margin: 0; color: #334155;">Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.</p>
</div>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Mittags:</p>
<p style="margin: 0; color: #334155;">Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.</p>
</div>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Nachmittags:</p>
<p style="margin: 0; color: #334155;">Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.</p>
</div>
<div class="tutorial-actions">
{% if is_admin %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_borrowings') }}">Admin-Bereich</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('logs') }}">Protokolle</a>
{% else %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
{% endif %}
</div>
</article>
</section>
</div>
<div class="tutorial-note">
Tipp: Sie können das Training jederzeit wieder öffnen und in Ruhe weiterklicken.
</div>
</div>
<script>
(function () {
const stepEls = Array.from(document.querySelectorAll('.workflow-step'));
const navBtns = Array.from(document.querySelectorAll('#workflowNav button[data-target-step]'));
const nextBtn = document.getElementById('nextStepBtn');
const prevBtn = document.getElementById('prevStepBtn');
const tutorialStateKey = 'inventarsystem_tutorial_state_v3_{{ username }}';
if (!stepEls.length) return;
let currentStep = 0;
let prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function loadState() {
try {
const raw = localStorage.getItem(tutorialStateKey);
if (!raw) return;
const parsed = JSON.parse(raw);
currentStep = Number.isInteger(parsed.currentStep) ? parsed.currentStep : 0;
} catch (e) {
currentStep = 0;
}
}
function saveState() {
localStorage.setItem(tutorialStateKey, JSON.stringify({
currentStep: currentStep
}));
}
function clampStep() {
if (currentStep < 0) currentStep = 0;
if (currentStep >= stepEls.length) currentStep = stepEls.length - 1;
}
function renderStep() {
clampStep();
stepEls.forEach(function (stepEl, idx) {
stepEl.classList.toggle('active', idx === currentStep);
if (idx === currentStep && !prefersReducedMotion) {
stepEl.animate(
[{ opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'translateY(0)' }],
{ duration: 260, easing: 'ease-out' }
);
}
});
navBtns.forEach(function (btn) {
const target = Number(btn.dataset.targetStep);
btn.classList.toggle('active', target === currentStep);
});
prevBtn.disabled = currentStep <= 0;
nextBtn.textContent = currentStep >= stepEls.length - 1 ? 'Fertig' : 'Nächster Schritt';
saveState();
}
navBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
currentStep = Number(btn.dataset.targetStep) || 0;
renderStep();
});
});
prevBtn.addEventListener('click', function () {
currentStep -= 1;
renderStep();
});
nextBtn.addEventListener('click', function () {
currentStep += 1;
renderStep();
});
loadState();
renderStep();
})();
</script>
{% endblock %}
+1837
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}Benutzer verwalten{% endblock %}
{% block content %}
<div class="container">
<h1>Benutzer verwalten</h1>
<div class="user-management-container">
<h2>Benutzer</h2>
<div class="filter-bar mb-3">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label for="filter-search" class="form-label">Suche</label>
<input type="text" class="form-control" id="filter-search" placeholder="Benutzername, Vor- oder Nachname...">
</div>
<div class="col-md-2">
<label for="filter-admin" class="form-label">Rolle</label>
<select class="form-select" id="filter-admin">
<option value="">Alle</option>
<option value="ja">Administrator</option>
<option value="nein">Standardbenutzer</option>
</select>
</div>
<div class="col-md-2">
<label for="filter-column" class="form-label">Sortieren nach</label>
<select class="form-select" id="filter-column">
<option value="0">Benutzername</option>
<option value="1">Vorname</option>
<option value="2">Nachname</option>
<option value="3">Rolle</option>
</select>
</div>
<div class="col-md-2">
<label for="filter-direction" class="form-label">Reihenfolge</label>
<select class="form-select" id="filter-direction">
<option value="asc">Aufsteigend</option>
<option value="desc">Absteigend</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button>
</div>
</div>
<div class="mt-2 text-muted small" id="filter-count"></div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover" id="user-table">
<thead class="thead-dark">
<tr>
<th>Benutzername</th>
<th>Vorname</th>
<th>Nachname</th>
<th>Administrator</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.name if user.name else user.username }}</td>
<td>{{ user.last_name if user.last_name else '' }}</td>
<td>{{ "Ja" if user.admin else "Nein" }}</td>
<td class="actions">
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
<input type="hidden" name="username" value="{{ user.username }}">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')">
Löschen
</button>
</form>
<button type="button" class="btn btn-primary btn-sm"
data-username="{{ user.username }}"
data-firstname="{{ user.name if user.name else '' }}"
data-lastname="{{ user.last_name if user.last_name else '' }}"
onclick="openEditUserModal(this)">
Bearbeiten
</button>
<button type="button" class="btn btn-warning btn-sm"
onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUserModalLabel">Benutzer bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin_update_user_name') }}">
<div class="modal-body">
<input type="hidden" id="edit-username" name="username">
<div class="mb-3">
<label for="edit-username-display" class="form-label">Benutzer:</label>
<input type="text" class="form-control" id="edit-username-display" disabled>
</div>
<div class="mb-3">
<label for="edit-name" class="form-label">Vorname:</label>
<input type="text" class="form-control" id="edit-name" name="name">
</div>
<div class="mb-3">
<label for="edit-last-name" class="form-label">Nachname:</label>
<input type="text" class="form-control" id="edit-last-name" name="last_name">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reset Password Modal -->
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="resetPasswordModalLabel">Passwort zurücksetzen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin_reset_user_password') }}">
<div class="modal-body">
<input type="hidden" id="reset-username" name="username">
<div class="mb-3">
<label for="username-display" class="form-label">Benutzer:</label>
<input type="text" class="form-control" id="username-display" disabled>
</div>
<div class="mb-3">
<label for="new-password" class="form-label">Neues Passwort:</label>
<input type="password" class="form-control" id="new-password" name="new_password" required>
<div class="password-requirements small text-muted mt-1">
<p>Das Passwort muss mindestens 6 Zeichen lang sein.</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Passwort zurücksetzen</button>
</div>
</form>
</div>
</div>
</div>
<script>
function applyFilters() {
var search = document.getElementById('filter-search').value.toLowerCase();
var adminFilter = document.getElementById('filter-admin').value.toLowerCase();
var colIndex = parseInt(document.getElementById('filter-column').value);
var direction = document.getElementById('filter-direction').value;
var table = document.getElementById('user-table');
var tbody = table.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
var visible = rows.filter(function(row) {
var cells = row.querySelectorAll('td');
var username = cells[0] ? cells[0].textContent.toLowerCase() : '';
var firstname = cells[1] ? cells[1].textContent.toLowerCase() : '';
var lastname = cells[2] ? cells[2].textContent.toLowerCase() : '';
var admin = cells[3] ? cells[3].textContent.toLowerCase().trim() : '';
var matchesSearch = !search ||
username.includes(search) ||
firstname.includes(search) ||
lastname.includes(search);
var matchesAdmin = !adminFilter || admin === adminFilter;
return matchesSearch && matchesAdmin;
});
var hidden = rows.filter(function(row) { return visible.indexOf(row) === -1; });
hidden.forEach(function(row) { row.style.display = 'none'; });
visible.sort(function(a, b) {
var cellA = a.querySelectorAll('td')[colIndex];
var cellB = b.querySelectorAll('td')[colIndex];
var valA = cellA ? cellA.textContent.trim().toLowerCase() : '';
var valB = cellB ? cellB.textContent.trim().toLowerCase() : '';
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
visible.forEach(function(row) {
row.style.display = '';
tbody.appendChild(row);
});
var countEl = document.getElementById('filter-count');
countEl.textContent = visible.length + ' von ' + rows.length + ' Benutzer angezeigt';
}
function resetFilters() {
document.getElementById('filter-search').value = '';
document.getElementById('filter-admin').value = '';
document.getElementById('filter-column').value = '0';
document.getElementById('filter-direction').value = 'asc';
applyFilters();
}
document.addEventListener('DOMContentLoaded', function() {
applyFilters();
document.getElementById('filter-search').addEventListener('input', applyFilters);
document.getElementById('filter-admin').addEventListener('change', applyFilters);
document.getElementById('filter-column').addEventListener('change', applyFilters);
document.getElementById('filter-direction').addEventListener('change', applyFilters);
});
function openEditUserModal(button) {
var username = button.getAttribute('data-username');
var name = button.getAttribute('data-firstname');
var lastName = button.getAttribute('data-lastname');
document.getElementById('edit-username').value = username;
document.getElementById('edit-username-display').value = username;
document.getElementById('edit-name').value = name;
document.getElementById('edit-last-name').value = lastName;
var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show();
}
function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username;
// Open modal using Bootstrap
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show();
}
</script>
<style>
.user-management-container {
margin-top: 20px;
}
.actions {
white-space: nowrap;
}
.actions form {
display: inline-block;
margin-right: 5px;
}
.filter-bar {
background-color: #f8f9fa;
padding: 15px;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.password-requirements {
background-color: #f8f9fa;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
}
.password-requirements p {
margin-bottom: 5px;
}
</style>
{% endblock %}
Executable
+499
View File
@@ -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
+144
View File
@@ -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 "$@"
Executable
+529
View File
@@ -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 <dir> Destination base directory (default: /var/backups)
# --db|--db-name <name> MongoDB database name (default: Inventarsystem)
# --uri|--mongo-uri <uri> MongoDB URI (default: mongodb://localhost:27017/)
# --invoice-archive-dir <dir> Invoice archive directory (default: <dest>/invoice-archive)
# --invoice-keep-days <N> Retention for invoice archive in days (default: 3650)
# --log <file> Log file (default: ./logs/backup.log)
# --no-compress Disable compression (keeps directory instead of .tar.gz)
# --compress-level <0-9> Compression level flag (only 0 is special here)
# --keep-days <N> Age-based retention in days (default: 7; 0 disables age filter)
# --min-keep <N> Always keep at least N most recent backups (default: 7)
# --mode <auto|host|docker> Backup mode (default: auto)
# -h|--help Show help
# ========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR}"
# Default destination now /var/backups; override with --dest or BACKUP_BASE_DIR env
BACKUP_BASE_DIR="${BACKUP_BASE_DIR:-/var/backups}"
LOG_DIR="$PROJECT_DIR/logs"
# Create only the log directory here; backup dir is created later with sudo if needed
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}"
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Options:
--dest|--out|--backup-dir <dir> Destination base directory (default: $BACKUP_BASE_DIR)
--db|--db-name <name> MongoDB database name (default: $DB_NAME)
--uri|--mongo-uri <uri> MongoDB URI (default: $MONGO_URI)
--invoice-archive-dir <dir> Invoice archive directory (default: $INVOICE_ARCHIVE_DIR)
--invoice-keep-days <N> Retention for invoice archive in days (default: $INVOICE_KEEP_DAYS)
--log <file> Log file (default: $LOG_FILE)
--no-compress Disable compression (keeps directory instead of .tar.gz)
--compress-level <0-9> Compression level flag (only 0 is special here)
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
-h|--help Show this help and exit
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
+107
View File
@@ -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
Executable
+218
View File
@@ -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 <<EOF
Usage: $0 [options]
Options:
--remove-cron Remove matching cron entries for old systems
--keep-autostart Stop services but do not disable autostart
--dry-run Show actions without executing
-h, --help Show this help message
EOF
}
log() {
echo "[cleanup-old] $*"
}
run_cmd() {
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] $*"
else
"$@"
fi
}
run_cmd_sudo() {
if [ "$(id -u)" -eq 0 ]; then
run_cmd "$@"
else
run_cmd sudo "$@"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--remove-cron)
REMOVE_CRON=1
shift
;;
--keep-autostart)
KEEP_AUTOSTART=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'"
usage
exit 2
;;
esac
done
}
service_exists() {
local service="$1"
systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}' | grep -Fxq "$service"
}
stop_services() {
if ! command -v systemctl >/dev/null 2>&1; then
log "systemctl not available, skipping service cleanup"
return 0
fi
local service
for service in "${SERVICES[@]}"; do
if ! service_exists "$service"; then
log "Service not found: $service"
continue
fi
log "Stopping $service"
run_cmd_sudo systemctl stop "$service" || true
if [ "$KEEP_AUTOSTART" -eq 0 ]; then
log "Disabling autostart for $service"
run_cmd_sudo systemctl disable "$service" || true
fi
done
}
kill_leftover_processes() {
local pattern
for pattern in "${PROCESS_PATTERNS[@]}"; do
log "Terminating processes matching: $pattern"
run_cmd_sudo pkill -TERM -f "$pattern" || true
done
if [ "$DRY_RUN" -eq 0 ]; then
sleep 2
fi
for pattern in "${PROCESS_PATTERNS[@]}"; do
run_cmd_sudo pkill -KILL -f "$pattern" || true
done
}
remove_stale_sockets() {
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
log "Removing stale socket/file: $socket_path"
run_cmd_sudo rm -f "$socket_path"
else
log "Socket/file not present: $socket_path"
fi
done
}
remove_cron_entries() {
if ! command -v crontab >/dev/null 2>&1; then
log "crontab not available, skipping cron cleanup"
return 0
fi
local tmp_file
tmp_file="$(mktemp)"
if [ "$(id -u)" -eq 0 ]; then
(crontab -l 2>/dev/null || true) > "$tmp_file"
else
(sudo crontab -l 2>/dev/null || true) > "$tmp_file"
fi
local pattern
for pattern in "${CRON_FILTER_PATTERNS[@]}"; do
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] would remove cron lines containing: $pattern"
else
grep -vF "$pattern" "$tmp_file" > "${tmp_file}.new" || true
mv "${tmp_file}.new" "$tmp_file"
fi
done
if [ "$DRY_RUN" -eq 0 ]; then
if [ "$(id -u)" -eq 0 ]; then
crontab "$tmp_file"
else
sudo crontab "$tmp_file"
fi
fi
rm -f "$tmp_file"
log "Cron cleanup finished"
}
status_report() {
log "Remaining matching processes:"
ps -eo pid,ppid,cmd --sort=start_time | grep -E "Inventarsystem/.venv/bin/gunicorn|admin_Inventarsystem/.venv/bin/gunicorn" | grep -v grep || echo "none"
log "Socket status:"
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
echo "exists: $socket_path"
else
echo "missing: $socket_path"
fi
done
}
main() {
parse_args "$@"
stop_services
kill_leftover_processes
remove_stale_sockets
if [ "$REMOVE_CRON" -eq 1 ]; then
remove_cron_entries
fi
status_report
log "Cleanup complete"
}
main "$@"
Executable
+73
View File
@@ -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)" }
}
}
+60
View File
@@ -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:
+31
View File
@@ -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 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
Executable
+1104
View File
File diff suppressed because it is too large Load Diff
Executable
+57
View File
@@ -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
Executable
+346
View File
@@ -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 <<EOF
Usage: $0 [options]
Options:
--migrate-legacy-db Back up host MongoDB and import into Docker MongoDB
--remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration
-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" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:$tag
EOF
sudo install -m 644 "$tmp_dir/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
elif sudo grep -q '^INVENTAR_APP_IMAGE=' "$PROJECT_DIR/.docker-build.env"; then
sudo sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:$tag|" "$PROJECT_DIR/.docker-build.env"
else
echo "INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:$tag" | sudo tee -a "$PROJECT_DIR/.docker-build.env" >/dev/null
fi
backup_legacy_database
echo "Starting stack..."
sudo bash "$PROJECT_DIR/start.sh"
restore_legacy_backup_into_docker
cleanup_old_services
cleanup_legacy_system
echo "Installation complete."
echo "Open: https://localhost"
}
main "$@"
+337
View File
@@ -0,0 +1,337 @@
#!/bin/bash
# Standalone version manager for this repo
# Supports pinning to a commit/tag/branch, one-time use, clearing the lock,
# applying the lock, listing refs, and showing status. Optional restart.
set -euo pipefail
PROJECT_DIR="$(dirname "$(readlink -f "$0")")"
cd "$PROJECT_DIR"
LOCK_FILE="$PROJECT_DIR/.version-lock"
RESTART=false
FORCE=false
BACKUP_BASE_DIR="/var/backups"
LOG_DIR="$PROJECT_DIR/logs"
VM_LOG="$LOG_DIR/version_manager.log"
# Directories/files to preserve across version switches
# Data paths we want to restore from backup after a version switch
DATA_PATHS=(
"certs"
"Images"
"logs"
"Web/uploads"
"Web/thumbnails"
"Web/QRCodes"
"Web/previews"
)
# Ensure log directory exists
mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true
chmod 777 "$LOG_DIR" >/dev/null 2>&1 || true
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$VM_LOG"; }
err() { echo "ERROR: $*" | tee -a "$VM_LOG" >&2; }
usage() {
cat <<USAGE
Usage:
$0 pin <ref> [--restart] [--force] Persistently lock to a commit/tag/branch and deploy it
$0 use <ref> [--restart] [--force] Use ref once (no persistence) and deploy it
$0 clear [--restart] [--force] Remove lock, switch back to main, pull latest
$0 apply [--restart] [--force] Re-apply the current lock (if any)
$0 status Show current commit and lock state
$0 list [--tags|--commits] List tags or recent commits (default: tags)
Notes:
- <ref> can be a tag name, branch name, or full/short commit hash
- --restart calls ./restart.sh after switching
- --force discards local changes (git reset --hard) if needed
USAGE
}
ensure_git_ready() {
# Avoid "dubious ownership" errors
git config --global --add safe.directory "$PROJECT_DIR" >/dev/null 2>&1 || true
# Fetch refs and tags
git fetch --all --tags --prune
}
ensure_clean_or_force() {
if ! git diff --quiet || ! git diff --cached --quiet; then
if [ "$FORCE" = true ]; then
log "Discarding local changes (force)"
git reset --hard
git clean -fdx >/dev/null 2>&1 || true
else
err "Working tree has local changes. Re-run with --force to discard."
exit 2
fi
fi
}
restart_if_requested() {
if [ "$RESTART" = true ]; then
if [ -x "$PROJECT_DIR/restart.sh" ]; then
log "Restarting services..."
"$PROJECT_DIR/restart.sh"
else
log "restart.sh not found or not executable; skipping restart"
fi
fi
}
# Create a full backup before switching versions (files + DB CSVs)
LAST_BACKUP_ARCHIVE=""
LAST_BACKUP_DIRNAME=""
create_pre_switch_backup() {
local ts backup_name backup_dir backup_archive
ts=$(date +"%Y-%m-%d_%H-%M-%S")
backup_name="Inventarsystem-pre-switch-$ts"
backup_dir="$BACKUP_BASE_DIR/$backup_name"
backup_archive="$BACKUP_BASE_DIR/$backup_name.tar.gz"
log "Creating pre-switch backup at $backup_archive"
sudo mkdir -p "$backup_dir" || { err "Failed to create backup directory"; return 1; }
# Copy project files (exclude .venv to reduce size, keep .git for reference)
log "Copying project files to backup directory..."
sudo rsync -a --delete \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
"$PROJECT_DIR/" "$backup_dir/" || log "Warning: rsync file copy encountered issues"
# Database CSV backup using existing helper
log "Backing up MongoDB to CSVs..."
sudo mkdir -p "$backup_dir/mongodb_backup" || true
if [ -x "$PROJECT_DIR/run-backup.sh" ]; then
if sudo -E "$PROJECT_DIR/run-backup.sh" --db Inventarsystem --uri mongodb://localhost:27017/ --out "$backup_dir/mongodb_backup" >> "$VM_LOG" 2>&1; then
log "Database backup completed"
else
err "Database backup failed; continuing with file backup only"
fi
else
log "run-backup.sh not found; skipping DB backup"
fi
# Compress backup
log "Compressing backup archive..."
if sudo tar -czf "$backup_archive" -C "$BACKUP_BASE_DIR" "$backup_name"; then
log "Backup archived to $backup_archive"
sudo rm -rf "$backup_dir" || true
# Set readable permissions
sudo chmod 644 "$backup_archive" || true
# Export for subsequent restore step
LAST_BACKUP_ARCHIVE="$backup_archive"
LAST_BACKUP_DIRNAME="$backup_name"
else
err "Failed to create compressed backup archive"
return 1
fi
}
# Restore key data from the backup archive into current working tree
restore_from_backup_archive() {
if [ -z "$LAST_BACKUP_ARCHIVE" ] || [ ! -f "$LAST_BACKUP_ARCHIVE" ]; then
log "No backup archive available to restore data from; skipping data restore"
return 0
fi
local tmpdir
tmpdir=$(mktemp -d /tmp/inventarsystem_restore_XXXXXX)
log "Extracting backup archive $LAST_BACKUP_ARCHIVE to $tmpdir"
if ! sudo tar -xzf "$LAST_BACKUP_ARCHIVE" -C "$tmpdir"; then
err "Failed to extract backup archive; skipping data restore"
rm -rf "$tmpdir" || true
return 1
fi
local src_root="$tmpdir/$LAST_BACKUP_DIRNAME"
# Some backups might extract without top-level dir; fallback
if [ ! -d "$src_root" ]; then
src_root="$tmpdir"
fi
log "Restoring data directories from backup..."
for rel in "${DATA_PATHS[@]}"; do
if [ -e "$src_root/$rel" ]; then
mkdir -p "$PROJECT_DIR/$(dirname "$rel")" 2>/dev/null || true
rsync -a "$src_root/$rel" "$PROJECT_DIR/$(dirname "$rel")/" >> "$VM_LOG" 2>&1 || log "Warning: failed to restore $rel"
log "Restored: $rel"
else
log "Not in backup (skipped): $rel"
fi
done
rm -rf "$tmpdir" || true
}
# Ensure permissions on critical data directories
ensure_permissions() {
# Logs should be writable
mkdir -p "$PROJECT_DIR/logs" && chmod 777 "$PROJECT_DIR/logs" 2>/dev/null || true
# Web data directories readable/executable by server
for d in "$PROJECT_DIR/Web/uploads" "$PROJECT_DIR/Web/thumbnails" "$PROJECT_DIR/Web/QRCodes" "$PROJECT_DIR/Web/previews" "$PROJECT_DIR/Images"; do
[ -d "$d" ] && chmod -R 755 "$d" 2>/dev/null || true
done
# SSL cert perms (key must be 600)
if [ -f "$PROJECT_DIR/certs/inventarsystem.key" ]; then
chmod 600 "$PROJECT_DIR/certs/inventarsystem.key" 2>/dev/null || true
fi
}
checkout_ref() {
local ref="$1"
# Try tag
if git show-ref --verify --quiet "refs/tags/$ref"; then
git checkout -B locked-tag-"$ref" "tags/$ref"
return 0
fi
# Try remote branch
if git show-ref --verify --quiet "refs/remotes/origin/$ref"; then
git checkout -B locked-branch-"$ref" "origin/$ref"
return 0
fi
# Try local branch
if git show-ref --verify --quiet "refs/heads/$ref"; then
git checkout "$ref"
return 0
fi
# Try commit-ish
if git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
git checkout -B locked-commit "$ref"
return 0
fi
err "Could not resolve ref '$ref' to tag/branch/commit"
exit 3
}
deploy_main_latest() {
ensure_clean_or_force
if git show-ref --verify --quiet refs/heads/main; then
git checkout main
else
git checkout -B main origin/main
fi
git pull --ff-only
}
cmd_status() {
local head_commit
head_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "Current commit: $head_commit"
if [ -f "$LOCK_FILE" ]; then
echo "Version lock: $(cat "$LOCK_FILE")"
else
echo "Version lock: none (tracking main)"
fi
}
cmd_list() {
local mode="tags"
for a in "$@"; do
case "$a" in
--tags) mode="tags" ;;
--commits) mode="commits" ;;
esac
done
ensure_git_ready
if [ "$mode" = "tags" ]; then
git tag --list | sort -V
else
git --no-pager log --oneline -n 30
fi
}
cmd_pin() {
local ref="$1"
ensure_git_ready
# Always produce a backup before switching
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
echo "$ref" > "$LOCK_FILE"
checkout_ref "$ref"
# Restore data from the backup archive
restore_from_backup_archive
ensure_permissions
log "Pinned to: $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_use() {
local ref="$1"
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
checkout_ref "$ref"
restore_from_backup_archive
ensure_permissions
log "Using (one-time): $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_clear() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
[ -f "$LOCK_FILE" ] && rm -f "$LOCK_FILE"
deploy_main_latest
restore_from_backup_archive
ensure_permissions
log "Cleared version lock; now on main @ $(git rev-parse --short HEAD)"
restart_if_requested
}
cmd_apply() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
if [ -f "$LOCK_FILE" ]; then
ensure_clean_or_force
local ref
ref=$(cat "$LOCK_FILE")
checkout_ref "$ref"
log "Applied lock: $ref (commit $(git rev-parse --short HEAD))"
else
log "No lock present; keeping main"
deploy_main_latest
fi
restore_from_backup_archive
ensure_permissions
restart_if_requested
}
# Parse global flags that can appear after subcommand too
parse_tail_flags() {
for a in "$@"; do
case "$a" in
--restart) RESTART=true ;;
--force) FORCE=true ;;
esac
done
}
main() {
local cmd="${1:-}"; shift || true
case "$cmd" in
pin)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_pin "$ref" ;;
use)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_use "$ref" ;;
clear)
parse_tail_flags "$@"; cmd_clear ;;
apply)
parse_tail_flags "$@"; cmd_apply ;;
status)
cmd_status ;;
list)
cmd_list "$@" ;;
--help|-h|help|"")
usage ;;
*)
err "Unknown command: $cmd"; usage; exit 1 ;;
esac
}
main "$@"
+119
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
flask
werkzeug
gunicorn
pymongo==4.6.3
pillow
qrcode
apscheduler
python-dateutil
pytz
requests
reportlab
python-barcode
Executable
+7
View File
@@ -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"
Executable
+527
View File
@@ -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 <<EOF
Inventarsystem Restore / Porting Tool (Docker-first)
Usage:
$0 --source <path> [options]
$0 --date <YYYY-MM-DD|latest> [options]
$0 --list
Options:
--source <path> Backup source path.
Supported:
- old folder with CSV files
- old Inventarsystem-*.tar.gz backup
- new mongodb-*.archive.gz backup
- folder containing mongodb-*.archive.gz
--date <value> Resolve source from known backup locations.
- YYYY-MM-DD: checks backups/YYYY-MM-DD and /var/backups/Inventarsystem-YYYY-MM-DD(.tar.gz)
- latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore
--list List detected backup candidates
--help Show this help
Notes:
- Always restores into current DB name: $DB_NAME
- Supports both legacy CSV and new archive backups
- Normalizes collection names to current structure (users, items, ausleihungen, filter_presets, settings)
EOF
}
setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose)
return 0
fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose)
return 0
fi
log "ERROR: docker compose is not available"
exit 1
}
compose() {
"${DOCKER_COMPOSE[@]}" "$@"
}
list_backups() {
echo "Detected backup candidates:"
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
}
resolve_latest_source() {
local latest
latest="$({
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
} | sort -nr | head -n1 | cut -d' ' -f2-)"
if [ -z "$latest" ]; then
log "ERROR: no backup candidates found"
exit 1
fi
SOURCE_PATH="$latest"
}
resolve_date_source() {
local d="$1"
if [ "$d" = "latest" ]; then
resolve_latest_source
return
fi
local p1="$BACKUP_ROOT_LOCAL/$d"
local p2="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d"
local p3="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d.tar.gz"
if [ -e "$p1" ]; then
SOURCE_PATH="$p1"
elif [ -e "$p2" ]; then
SOURCE_PATH="$p2"
elif [ -e "$p3" ]; then
SOURCE_PATH="$p3"
else
log "ERROR: no backup found for date $d"
exit 1
fi
}
ensure_stack() {
log "Ensuring mongodb/app services are running..."
compose up -d mongodb app >/dev/null
if ! compose ps --status running mongodb | grep -q mongodb; then
log "ERROR: mongodb container is not running"
exit 1
fi
if ! compose ps --status running app | grep -q app; then
log "ERROR: app container is not running"
exit 1
fi
}
stage_source() {
local src="$1"
if [ ! -e "$src" ]; then
log "ERROR: source path does not exist: $src"
exit 1
fi
if [ -f "$src" ] && [[ "$src" == *.tar.gz ]]; then
log "Extracting tar backup: $src"
tar -xzf "$src" -C "$WORK_DIR"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -f "$src" ]; then
cp -f "$src" "$WORK_DIR/"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -d "$src" ]; then
STAGED_PATH="$src"
return
fi
log "ERROR: unsupported source type: $src"
exit 1
}
find_archive_file() {
local root="$1"
find "$root" -type f \( -name "*.archive.gz" -o -name "*.archive" \) 2>/dev/null | head -n1 || true
}
find_best_csv_dir() {
local root="$1"
local best_dir=""
local best_count=0
while IFS= read -r dir; do
[ -z "$dir" ] && continue
local count
count="$(find "$dir" -maxdepth 1 -type f -name '*.csv' | wc -l | tr -d ' ')"
if [ "$count" -gt "$best_count" ]; then
best_count="$count"
best_dir="$dir"
fi
done < <(find "$root" -type f -name '*.csv' -printf '%h\n' 2>/dev/null | sort -u)
if [ "$best_count" -gt 0 ]; then
echo "$best_dir"
fi
}
normalize_structure() {
local py="$WORK_DIR/normalize_db.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
rename_map = {
"user": "users",
"item": "items",
"ausleihung": "ausleihungen",
"filter_preset": "filter_presets",
"preset": "filter_presets",
"setting": "settings",
}
for old, new in rename_map.items():
if old not in db.list_collection_names():
continue
if new not in db.list_collection_names():
db[old].rename(new)
print(f"Renamed collection {old} -> {new}")
continue
moved = 0
for doc in db[old].find({}):
db[new].insert_one(doc)
moved += 1
db[old].drop()
print(f"Merged {moved} docs from {old} into {new}")
print("Final collections:", sorted(db.list_collection_names()))
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/normalize_db.py"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/normalize_db.py
}
import_archive() {
local archive_file="$1"
local mongo_cid
mongo_cid="$(compose ps -q mongodb)"
if [ -z "$mongo_cid" ]; then
log "ERROR: could not resolve mongodb container id"
exit 1
fi
log "Importing archive backup: $archive_file"
$SUDO docker cp "$archive_file" "$mongo_cid:/tmp/restore.archive.gz"
local drop_flag=""
if [ "$DROP_DATABASE" = true ]; then
drop_flag="--drop"
fi
compose exec -T mongodb sh -lc "mongorestore --archive=/tmp/restore.archive.gz --gzip $drop_flag"
compose exec -T mongodb rm -f /tmp/restore.archive.gz >/dev/null 2>&1 || true
normalize_structure
}
import_csv() {
local csv_dir="$1"
local app_cid py
app_cid="$(compose ps -q app)"
if [ -z "$app_cid" ]; then
log "ERROR: could not resolve app container id"
exit 1
fi
log "Importing CSV backup directory: $csv_dir"
compose exec -T app sh -lc "rm -rf /tmp/restore_csv && mkdir -p /tmp/restore_csv"
$SUDO docker cp "$csv_dir/." "$app_cid:/tmp/restore_csv"
py="$WORK_DIR/import_csv.py"
cat > "$py" <<'PY'
import os
import csv
import ast
import re
from pymongo import MongoClient
from bson.objectid import ObjectId
DB_NAME = os.getenv("DB_NAME", "Inventarsystem")
CSV_DIR = os.getenv("CSV_DIR", "/tmp/restore_csv")
DROP_DATABASE = os.getenv("DROP_DATABASE", "false").lower() == "true"
HOST = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
PORT = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
COLLECTION_MAP = {
"users": "users",
"user": "users",
"items": "items",
"item": "items",
"ausleihungen": "ausleihungen",
"ausleihung": "ausleihungen",
"filter_presets": "filter_presets",
"filter_preset": "filter_presets",
"settings": "settings",
"setting": "settings",
}
BOOL_FIELDS = {"Admin", "Verfuegbar", "enabled"}
INT_FIELDS = {"Anschaffungskosten", "filter_num"}
OID_LIKE_FIELDS = {"_id", "Item"}
client = MongoClient(HOST, PORT)
db = client[DB_NAME]
if DROP_DATABASE:
client.drop_database(DB_NAME)
db = client[DB_NAME]
def parse_scalar(key, value):
if value is None:
return None
value = value.strip()
if value == "":
return None
if key in BOOL_FIELDS:
lowered = value.lower()
if lowered == "true":
return True
if lowered == "false":
return False
if key in INT_FIELDS:
try:
return int(value)
except Exception:
pass
if key in OID_LIKE_FIELDS and re.fullmatch(r"[0-9a-fA-F]{24}", value):
return ObjectId(value)
if value.startswith("[") and value.endswith("]"):
try:
return ast.literal_eval(value)
except Exception:
pass
return value
def load_csv_file(csv_path):
docs = []
with open(csv_path, "r", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
doc = {}
for key, value in row.items():
doc[key] = parse_scalar(key, value)
docs.append(doc)
return docs
csv_files = [
os.path.join(CSV_DIR, f)
for f in sorted(os.listdir(CSV_DIR))
if f.endswith(".csv")
]
if not csv_files:
raise SystemExit(f"No CSV files found in {CSV_DIR}")
for csv_file in csv_files:
base = os.path.splitext(os.path.basename(csv_file))[0]
coll = COLLECTION_MAP.get(base, base)
docs = load_csv_file(csv_file)
db[coll].drop()
if docs:
db[coll].insert_many(docs)
print(f"Imported {len(docs)} docs into {coll} from {os.path.basename(csv_file)}")
print("Collections after CSV import:", sorted(db.list_collection_names()))
PY
$SUDO docker cp "$py" "$app_cid:/tmp/import_csv.py"
compose exec -T app env DB_NAME="$DB_NAME" CSV_DIR="/tmp/restore_csv" DROP_DATABASE="$DROP_DATABASE" python /tmp/import_csv.py
normalize_structure
}
print_counts() {
local py="$WORK_DIR/print_counts.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
for name in ["users", "items", "ausleihungen", "filter_presets", "settings"]:
try:
count = db[name].count_documents({})
except Exception:
count = -1
print(f"{name}: {count}")
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/print_counts.py"
log "Post-restore collection counts:"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/print_counts.py | tee -a "$LOG_FILE"
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--source)
SOURCE_PATH="${2:-}"
shift 2
;;
--date)
BACKUP_DATE="${2:-}"
shift 2
;;
--drop-database)
DROP_DATABASE=true
shift
;;
--restart-services)
RESTART_SERVICES=true
shift
;;
--list)
list_backups
exit 0
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown argument: $1"
show_help
exit 1
;;
esac
done
}
main() {
parse_args "$@"
setup_compose
if [ -n "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
log "ERROR: use either --source or --date, not both"
exit 1
fi
if [ -z "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
resolve_date_source "$BACKUP_DATE"
fi
if [ -z "$SOURCE_PATH" ]; then
log "ERROR: provide --source <path> or --date <YYYY-MM-DD|latest>"
show_help
exit 1
fi
ensure_stack
local staged archive_file csv_dir
stage_source "$SOURCE_PATH"
staged="$STAGED_PATH"
archive_file="$(find_archive_file "$staged")"
if [ -n "$archive_file" ]; then
import_archive "$archive_file"
print_counts
else
csv_dir="$(find_best_csv_dir "$staged")"
if [ -z "$csv_dir" ]; then
log "ERROR: no supported backup data found under: $SOURCE_PATH"
log "Expected either *.archive.gz or CSV files"
exit 1
fi
import_csv "$csv_dir"
print_counts
fi
if [ "$RESTART_SERVICES" = true ]; then
log "Restarting services..."
"$SCRIPT_DIR/restart.sh"
fi
log "Restore completed successfully into DB: $DB_NAME"
}
main "$@"
Executable
+47
View File
@@ -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
Executable
+574
View File
@@ -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 <<EOF
Usage: $0 [options]
Options:
--no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default)
-h, --help Show this help message
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--no-cron)
CRON_SETUP_VALUE="0"
shift
;;
--with-cron)
CRON_SETUP_VALUE="1"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'"
usage
exit 2
;;
esac
done
}
cron_setup_enabled() {
case "${CRON_SETUP_VALUE,,}" in
0|false|no|off)
return 1
;;
*)
return 0
;;
esac
}
apt_install() {
$SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
}
install_docker_engine() {
if command -v docker >/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 <<EOF
[Unit]
Description=Inventarsystem Docker Stack Autostart
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
RequiresMountsFor=$SCRIPT_DIR
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=$SCRIPT_DIR
Environment=INVENTAR_SKIP_AUTOSTART_SETUP=1
Environment=INVENTAR_SETUP_CRON=0
ExecStart=/bin/bash "$SCRIPT_DIR/start.sh" --no-cron
ExecStop=/bin/bash "$SCRIPT_DIR/stop.sh"
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
EOF
$SUDO systemctl daemon-reload
fi
$SUDO systemctl enable "$service_name" >/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 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
echo "Recreated missing nginx config at $config_path"
fi
}
ensure_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" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
INVENTAR_HTTPS_PORT=$HTTPS_PORT_VALUE
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
EOF
}
verify_stack_health() {
local compose_args running_services retry_count=0
compose_args=(--env-file "$ENV_FILE")
# Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do
echo "Waiting for containers to become healthy... (attempt $((retry_count + 1))/2)"
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
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://<server-ip>:$HTTPS_PORT_VALUE"
Executable
+15
View File
@@ -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."
Executable
+473
View File
@@ -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 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
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" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=$app_image
EOF
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
sed -i "s|^INVENTAR_APP_IMAGE=.*|INVENTAR_APP_IMAGE=$app_image|" "$ENV_FILE"
else
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$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 "$@"
Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB