Initial Commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
logs
|
||||||
|
certs
|
||||||
|
build
|
||||||
Executable
+134
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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!
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
Inventarsystem
|
||||||
|
Copyright © 2025-2026 AIIrondev
|
||||||
|
|
||||||
|
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||||
|
You may not use this project except in compliance with the License.
|
||||||
|
The full license text can be found in the file Legal/LICENSE.
|
||||||
|
|
||||||
|
This software is provided for private, non-commercial use only.
|
||||||
|
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||||
|
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||||
|
|
||||||
|
Third-party notices
|
||||||
|
This project depends on, uses, or references the following third‑party
|
||||||
|
software. Their licenses apply as noted. Where applicable, copyright
|
||||||
|
notices are reproduced below. This NOTICE is for attribution only and
|
||||||
|
does not modify the terms of the licenses.
|
||||||
|
|
||||||
|
Python packages (runtime):
|
||||||
|
- Flask (BSD-3-Clause) © Pallets
|
||||||
|
- Werkzeug (BSD-3-Clause) © Pallets
|
||||||
|
- Jinja2 (BSD-3-Clause) © Pallets (indirect via Flask)
|
||||||
|
- Click (BSD-3-Clause) © Pallets (indirect via Flask)
|
||||||
|
- Gunicorn (MIT) © 2009-2025 Benoit Chesneau and contributors
|
||||||
|
- PyMongo (Apache-2.0) © MongoDB, Inc.
|
||||||
|
- APScheduler (MIT) © 2009-2025 Alex Grönholm and contributors
|
||||||
|
- Pillow (HPND/FreeType-style license) © 1995-2011 Fredrik Lundh and contributors; © 2010-2025 Alex Clark and contributors
|
||||||
|
- python-dateutil (BSD-3-Clause) © 2017-2025 dateutil team; © 2003-2011 Gustavo Niemeyer
|
||||||
|
- pytz (MIT) © 2003-2025 Stuart Bishop
|
||||||
|
- requests (Apache-2.0) © 2011-2025 Kenneth Reitz and contributors
|
||||||
|
- qrcode (BSD-3-Clause) © 2010-2025 Lincoln Loop and contributors
|
||||||
|
|
||||||
|
Frontend/runtime assets:
|
||||||
|
- html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)
|
||||||
|
|
||||||
|
System components (not distributed with this repository, may be used in deployment):
|
||||||
|
- NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors
|
||||||
|
- FFmpeg (LGPL/GPL, depending on configuration) © 2000-2025 FFmpeg developers
|
||||||
|
|
||||||
|
Your modifications
|
||||||
|
If you create Derivative Works and distribute them, please add your own
|
||||||
|
attribution statements here, alongside the above notices, and indicate
|
||||||
|
any files you changed as required by the Apache License, Section 4.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
# Inventarsystem
|
||||||
|
|
||||||
|
[](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
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+1226
File diff suppressed because it is too large
Load Diff
Executable
+45
@@ -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
|
||||||
Executable
+103
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
flask
|
||||||
|
bson
|
||||||
|
werkzeug
|
||||||
|
gunicorn
|
||||||
|
pymongo
|
||||||
|
pillow
|
||||||
|
qrcode
|
||||||
|
apscheduler
|
||||||
|
python-dateutil
|
||||||
|
pytz
|
||||||
|
requests
|
||||||
|
reportlab
|
||||||
|
python-barcode
|
||||||
+175
@@ -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)
|
||||||
Executable
+35
@@ -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-----
|
||||||
Executable
+52
@@ -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-----
|
||||||
Executable
+99
@@ -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;
|
||||||
|
}
|
||||||
Executable
+806
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -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 |
Executable
+400
@@ -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);
|
||||||
|
}
|
||||||
Executable
+285
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+24
@@ -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);
|
||||||
@@ -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 %}
|
||||||
Executable
+915
@@ -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>
|
||||||
Executable
+144
@@ -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 %}
|
||||||
Executable
+288
@@ -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>
|
||||||
Executable
+60
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
Executable
+394
@@ -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 & 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 & 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 %}
|
||||||
Executable
+126
@@ -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 %}
|
||||||
Executable
+378
@@ -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 %}
|
||||||
Executable
+4170
File diff suppressed because it is too large
Load Diff
Executable
+4987
File diff suppressed because it is too large
Load Diff
Executable
+100
@@ -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 %}
|
||||||
Executable
+200
@@ -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 %}
|
||||||
Executable
+642
@@ -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 %}
|
||||||
Executable
+338
@@ -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 %}
|
||||||
Executable
+307
@@ -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>
|
||||||
Executable
+816
@@ -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()">×</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>
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
Executable
+1629
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||||
Executable
+1837
File diff suppressed because it is too large
Load Diff
Executable
+290
@@ -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
@@ -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
|
||||||
Executable
+144
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
Executable
+107
@@ -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
@@ -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
@@ -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)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+57
@@ -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
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
||||||
Executable
+119
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
flask
|
||||||
|
werkzeug
|
||||||
|
gunicorn
|
||||||
|
pymongo==4.6.3
|
||||||
|
pillow
|
||||||
|
qrcode
|
||||||
|
apscheduler
|
||||||
|
python-dateutil
|
||||||
|
pytz
|
||||||
|
requests
|
||||||
|
reportlab
|
||||||
|
python-barcode
|
||||||
Executable
+7
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
@@ -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 "$@"
|
||||||
Reference in New Issue
Block a user