diff --git a/.gitignore b/.gitignore index 169f5d4..2c982bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv __pycache__ Inventarsystem_Lizenz_Verwaltung -main.dist \ No newline at end of file +main.dist +.mongo-data \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..64f45ae --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python-envs.pythonProjects": [ + { + "path": "Website/.venv", + "envManager": "ms-python.python:venv", + "packageManager": "ms-python.python:pip" + } + ] +} \ No newline at end of file diff --git a/Inventarsystem-Lizenz-Verwaltung/run.sh b/Inventarsystem-Lizenz-Verwaltung/run.sh index 4ec93a3..0b36539 100755 --- a/Inventarsystem-Lizenz-Verwaltung/run.sh +++ b/Inventarsystem-Lizenz-Verwaltung/run.sh @@ -40,10 +40,10 @@ if settings_path.is_file(): version_file.write_text(f"{version}\n", encoding="utf-8") print(f"Prepared {version_file} with version: {version}") PY -python -m nuitka --standalone --follow-imports --include-data-dir=templates=templates --include-data-dir=static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=Inventarsystem_Lizenz_Verwaltung --remove-output main.py -if [[ -x "./Inventarsystem_Lizenz_Verwaltung/main.bin" ]]; then - ./Inventarsystem_Lizenz_Verwaltung/main.bin +python -m nuitka --standalone --follow-imports --include-data-dir=templates=templates --include-data-dir=static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=build --remove-output main.py +if [[ -x "./build/main.bin" ]]; then + ./build/main.bin else - echo "Build step finished but executable not found at ./Inventarsystem_Lizenz_Verwaltung/main.bin" + echo "Build step finished but executable not found at ./build/main.bin" exit 1 fi \ No newline at end of file diff --git a/Website/data/appointments.json b/Website/data/appointments.json index b9371ef..004293e 100644 --- a/Website/data/appointments.json +++ b/Website/data/appointments.json @@ -12,5 +12,33 @@ "response": "Es geht leider nicht um die von ihnen angefragte Uhrzeit, bitte versuchen sie es zwei Tage sp\u00e4ter", "responded_at": "2026-03-23T16:06:52Z", "responded_by": "Aiirondev" + }, + { + "id": "a-1774287654152", + "username": "Aiirondev", + "display_name": "Maximilian Gr\u00fcndinger", + "date": "2026-03-26", + "time": "10:00", + "subject": "Test", + "note": "Test", + "status": "Bestaetigt", + "created_at": "2026-03-23T17:40:54Z", + "response": "Wunderbar bis dann!", + "responded_at": "2026-03-23T17:41:10Z", + "responded_by": "Aiirondev" + }, + { + "id": "a-1774296722216", + "username": "Aiirondev", + "display_name": "Maximilian Gr\u00fcndinger", + "date": "2026-03-27", + "time": "14:30", + "subject": "Erst Gespr\u00e4ch f\u00fcr Grunschule ...", + "note": "Erste kontakt aufnahme", + "status": "Bestaetigt", + "created_at": "2026-03-23T20:12:02Z", + "response": "Ja passt so, wir kommen vorbei", + "responded_at": "2026-03-23T20:12:43Z", + "responded_by": "Aiirondev" } ] \ No newline at end of file diff --git a/Website/main.py b/Website/main.py index 8f905b5..40f2caf 100644 --- a/Website/main.py +++ b/Website/main.py @@ -9,6 +9,11 @@ from functools import wraps from werkzeug.security import generate_password_hash, check_password_hash import bleach from markupsafe import escape +from pymongo import MongoClient +from pymongo.errors import PyMongoError +from bson.objectid import ObjectId + +import user as user_store app = Flask(__name__) app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4s" @@ -35,12 +40,102 @@ DATA_DIR = BASE_DIR / "data" USERS_FILE = DATA_DIR / "users.json" APPOINTMENTS_FILE = DATA_DIR / "appointments.json" POSTS_FILE = DATA_DIR / "posts.json" +MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017") +MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Inventarsystem") def _issue_access_token() -> str: return create_access_token(identity="license-validation-client") +def _utc_now_iso() -> str: + return datetime.utcnow().isoformat(timespec="seconds") + "Z" + + +def _get_mongo_client() -> MongoClient: + return MongoClient(MONGO_URI, serverSelectionTimeoutMS=1200) + + +def _get_mongo_db(): + client = _get_mongo_client() + return client, client[MONGO_DB_NAME] + + +def _normalize_user_doc(doc: dict | None) -> dict | None: + if not doc: + return None + return { + "username": doc.get("Username") or doc.get("username"), + "display_name": doc.get("name") or doc.get("display_name") or doc.get("Username"), + "is_admin": bool(doc.get("Admin") or doc.get("is_admin", False)), + "created_at": doc.get("created_at"), + } + + +def _get_collection(name: str): + client, db = _get_mongo_db() + return client, db[name] + + +def _list_users_for_admin() -> list: + docs = user_store.get_all_users() or [] + users = [] + for doc in docs: + normalized = _normalize_user_doc(doc) + if normalized and normalized.get("username"): + users.append(normalized) + users.sort(key=lambda item: (item.get("is_admin", False), item.get("username", "")), reverse=True) + return users + + +def _ensure_user_license(username: str, display_name: str) -> None: + client = None + try: + client, licenses = _get_collection("licenses") + if licenses.find_one({"username": username}): + return + licenses.insert_one( + { + "username": username, + "school_name": f"{display_name} Schule", + "license_key": f"LIC-{int(datetime.utcnow().timestamp())}-{username[:3].upper()}", + "plan": "Standard", + "status": "Aktiv", + "valid_until": "2027-12-31", + "created_at": _utc_now_iso(), + } + ) + except PyMongoError: + return + finally: + if client: + client.close() + + +def _ensure_user_invoice(username: str) -> None: + client = None + try: + client, invoices = _get_collection("invoices") + if invoices.find_one({"username": username}): + return + invoices.insert_one( + { + "username": username, + "invoice_number": f"INV-{datetime.utcnow().strftime('%Y%m')}-{username[:3].upper()}", + "period": datetime.utcnow().strftime("%m/%Y"), + "amount_eur": 79.0, + "status": "Offen", + "due_date": "2026-12-31", + "created_at": _utc_now_iso(), + } + ) + except PyMongoError: + return + finally: + if client: + client.close() + + def _ensure_data_files() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) if not USERS_FILE.exists(): @@ -93,14 +188,28 @@ def _validate_username(username: str) -> bool: def _find_user(username: str): - username_key = (username or "").strip().lower() + username_key = (username or "").strip() if not username_key: return None - users = _read_json(USERS_FILE) - for entry in users: - if entry.get("username", "").lower() == username_key: - return entry - return None + + try: + # First try an exact lookup using the original username. + raw_user = user_store.get_user(username_key) + normalized = _normalize_user_doc(raw_user) + if normalized: + return normalized + + # Fallback: case-insensitive lookup to avoid role-check failures + # when session username casing differs from stored Username casing. + all_users = user_store.get_all_users() or [] + for entry in all_users: + candidate = (entry.get("Username") or entry.get("username") or "").strip() + if candidate.lower() == username_key.lower(): + return _normalize_user_doc(entry) + + return None + except Exception: + return None def login_required(view_func): @@ -154,13 +263,20 @@ def login(): flash('Bitte Benutzername und Passwort eingeben.', 'error') return redirect(url_for('login')) - stored_user = _find_user(username) + try: + stored_user_raw = user_store.check_nm_pwd(username, password) + except Exception: + stored_user_raw = None - if stored_user and check_password_hash(stored_user.get("password_hash", ""), password): + stored_user = _normalize_user_doc(stored_user_raw) + + if stored_user: session['username'] = stored_user.get("username") session['display_name'] = stored_user.get("display_name") or stored_user.get("username") session['is_admin'] = stored_user.get("is_admin", False) session['access_token'] = _issue_access_token() + _ensure_user_license(session['username'], session['display_name']) + _ensure_user_invoice(session['username']) if request.is_json: return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200 @@ -205,18 +321,18 @@ def register(): display_name = _sanitize_text(display_name, 100) - users = _read_json(USERS_FILE) - is_admin = len(users) == 0 - users.append( - { - "username": username, - "display_name": display_name, - "password_hash": generate_password_hash(password), - "is_admin": is_admin, - "created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", - } - ) - _write_json(USERS_FILE, users) + try: + existing_users = user_store.get_all_users() or [] + is_first_user = len(existing_users) == 0 + if not user_store.add_user(username, password, display_name, ""): + flash("Benutzer konnte nicht erstellt werden.", "error") + return redirect(url_for("register")) + if is_first_user: + user_store.make_admin(username) + except Exception: + flash("MongoDB ist derzeit nicht erreichbar.", "error") + return redirect(url_for("register")) + flash("Registrierung erfolgreich. Bitte jetzt einloggen.", "success") return redirect(url_for("login")) @@ -441,6 +557,419 @@ def blog_post(post_id): return render_template("blog_post.html", post=post) +@app.route('/my/licenses') +@login_required +def my_licenses(): + licenses = [] + client = None + try: + client, col = _get_collection("licenses") + licenses = list(col.find({"username": session.get("username")}, {"_id": 0}).sort("created_at", -1)) + except PyMongoError: + flash("Lizenzdaten konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + return render_template("my_licenses.html", licenses=licenses) + + +@app.route('/my/invoices') +@login_required +def my_invoices(): + invoices = [] + client = None + try: + client, col = _get_collection("invoices") + invoices = list(col.find({"username": session.get("username")}, {"_id": 0}).sort("created_at", -1)) + except PyMongoError: + flash("Rechnungen konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + return render_template("my_invoices.html", invoices=invoices) + + +@app.route('/chat', methods=['GET', 'POST']) +@login_required +def user_chat(): + client = None + if request.method == 'POST': + message = _sanitize_text(request.form.get("message") or "", 3000) + if not message: + flash("Bitte eine Nachricht eingeben.", "error") + return redirect(url_for("user_chat")) + try: + client, col = _get_collection("chat_messages") + col.insert_one( + { + "username": session.get("username"), + "sender": session.get("display_name"), + "sender_role": "user", + "message": message, + "created_at": _utc_now_iso(), + } + ) + flash("Nachricht gesendet.", "success") + except PyMongoError: + flash("Nachricht konnte nicht gesendet werden.", "error") + finally: + if client: + client.close() + return redirect(url_for("user_chat")) + + messages = [] + try: + client, col = _get_collection("chat_messages") + messages = list(col.find({"username": session.get("username")}, {"_id": 0}).sort("created_at", 1)) + except PyMongoError: + flash("Chat konnte nicht geladen werden.", "error") + finally: + if client: + client.close() + return render_template("chat.html", messages=messages) + + +@app.route('/tickets', methods=['GET', 'POST']) +@login_required +def user_tickets(): + client = None + if request.method == 'POST': + title = _sanitize_text(request.form.get("title") or "", 200) + description = _sanitize_text(request.form.get("description") or "", 5000) + priority = _sanitize_text(request.form.get("priority") or "Normal", 30) + if not title or not description: + flash("Bitte Titel und Beschreibung ausfuellen.", "error") + return redirect(url_for("user_tickets")) + try: + client, col = _get_collection("support_tickets") + col.insert_one( + { + "username": session.get("username"), + "display_name": session.get("display_name"), + "title": title, + "description": description, + "priority": priority, + "status": "Offen", + "admin_response": "", + "created_at": _utc_now_iso(), + "updated_at": _utc_now_iso(), + } + ) + flash("Support-Ticket erstellt.", "success") + except PyMongoError: + flash("Support-Ticket konnte nicht erstellt werden.", "error") + finally: + if client: + client.close() + return redirect(url_for("user_tickets")) + + tickets = [] + try: + client, col = _get_collection("support_tickets") + tickets = list(col.find({"username": session.get("username")}).sort("created_at", -1)) + for ticket in tickets: + ticket["id"] = str(ticket.get("_id")) + except PyMongoError: + flash("Tickets konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + + return render_template("tickets.html", tickets=tickets) + + +@app.route('/admin/users', methods=['GET', 'POST']) +@admin_required +def admin_users(): + if request.method == 'POST': + action = _sanitize_text(request.form.get("action") or "", 50) + username = _sanitize_text(request.form.get("username") or "", 80) + + if not username: + flash("Bitte Benutzername angeben.", "error") + return redirect(url_for("admin_users")) + + if action == "make_admin": + user_store.make_admin(username) + flash("Benutzer zum Admin gemacht.", "success") + elif action == "remove_admin": + user_store.remove_admin(username) + flash("Admin-Rechte entfernt.", "success") + elif action == "delete_user": + if username == session.get("username"): + flash("Sie koennen Ihr eigenes Konto nicht loeschen.", "error") + return redirect(url_for("admin_users")) + user_store.delete_user(username) + flash("Benutzer geloescht.", "success") + else: + flash("Ungueltige Aktion.", "error") + return redirect(url_for("admin_users")) + + users = _list_users_for_admin() + return render_template("admin_users.html", users=users) + + +@app.route('/admin/chats', methods=['GET', 'POST']) +@admin_required +def admin_chats(): + client = None + selected_user = _sanitize_text(request.args.get("username") or request.form.get("username") or "", 80) + + if request.method == 'POST': + message = _sanitize_text(request.form.get("message") or "", 3000) + if not selected_user or not message: + flash("Bitte Empfaenger und Nachricht angeben.", "error") + return redirect(url_for("admin_chats")) + try: + client, col = _get_collection("chat_messages") + col.insert_one( + { + "username": selected_user, + "sender": session.get("display_name") or session.get("username"), + "sender_role": "admin", + "message": message, + "created_at": _utc_now_iso(), + } + ) + flash("Antwort gesendet.", "success") + except PyMongoError: + flash("Antwort konnte nicht gesendet werden.", "error") + finally: + if client: + client.close() + return redirect(url_for("admin_chats", username=selected_user)) + + conversations = [] + messages = [] + try: + client, col = _get_collection("chat_messages") + conversations = sorted(col.distinct("username")) + if selected_user: + messages = list(col.find({"username": selected_user}, {"_id": 0}).sort("created_at", 1)) + except PyMongoError: + flash("Admin-Chat konnte nicht geladen werden.", "error") + finally: + if client: + client.close() + + return render_template( + "admin_chats.html", + conversations=conversations, + selected_user=selected_user, + messages=messages, + ) + + +@app.route('/admin/tickets', methods=['GET', 'POST']) +@admin_required +def admin_tickets(): + client = None + if request.method == 'POST': + ticket_id = _sanitize_text(request.form.get("ticket_id") or "", 64) + status = _sanitize_text(request.form.get("status") or "", 40) + admin_response = _sanitize_text(request.form.get("admin_response") or "", 5000) + if not ticket_id: + flash("Ticket-ID fehlt.", "error") + return redirect(url_for("admin_tickets")) + + try: + client, col = _get_collection("support_tickets") + col.update_one( + {"_id": ObjectId(ticket_id)}, + { + "$set": { + "status": status or "In Bearbeitung", + "admin_response": admin_response, + "updated_at": _utc_now_iso(), + } + }, + ) + flash("Ticket aktualisiert.", "success") + except Exception: + flash("Ticket konnte nicht aktualisiert werden.", "error") + finally: + if client: + client.close() + return redirect(url_for("admin_tickets")) + + tickets = [] + try: + client, col = _get_collection("support_tickets") + tickets = list(col.find().sort("created_at", -1)) + for ticket in tickets: + ticket["id"] = str(ticket.get("_id")) + except PyMongoError: + flash("Support-Tickets konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + + return render_template("admin_tickets.html", tickets=tickets) + + +@app.route('/admin/licenses', methods=['GET', 'POST']) +@admin_required +def admin_licenses(): + client = None + if request.method == 'POST': + action = _sanitize_text(request.form.get("action") or "", 50) + license_id = _sanitize_text(request.form.get("license_id") or "", 64) + + try: + client, col = _get_collection("licenses") + + if action == "create": + username = _sanitize_text(request.form.get("username") or "", 80) + school_name = _sanitize_text(request.form.get("school_name") or "", 200) + plan = _sanitize_text(request.form.get("plan") or "Standard", 80) + status = _sanitize_text(request.form.get("status") or "Aktiv", 40) + valid_until = _sanitize_text(request.form.get("valid_until") or "", 40) + + if not username or not school_name: + flash("Bitte Benutzername und Schule angeben.", "error") + return redirect(url_for("admin_licenses")) + + col.insert_one( + { + "username": username, + "school_name": school_name, + "license_key": f"LIC-{int(datetime.utcnow().timestamp())}-{username[:3].upper()}", + "plan": plan, + "status": status, + "valid_until": valid_until or "2027-12-31", + "created_at": _utc_now_iso(), + } + ) + flash("Lizenz angelegt.", "success") + + elif action == "update" and license_id: + col.update_one( + {"_id": ObjectId(license_id)}, + { + "$set": { + "plan": _sanitize_text(request.form.get("plan") or "Standard", 80), + "status": _sanitize_text(request.form.get("status") or "Aktiv", 40), + "valid_until": _sanitize_text(request.form.get("valid_until") or "", 40), + } + }, + ) + flash("Lizenz aktualisiert.", "success") + + elif action == "delete" and license_id: + col.delete_one({"_id": ObjectId(license_id)}) + flash("Lizenz geloescht.", "success") + else: + flash("Ungueltige Aktion.", "error") + except Exception: + flash("Lizenzverwaltung fehlgeschlagen.", "error") + finally: + if client: + client.close() + + return redirect(url_for("admin_licenses")) + + licenses = [] + try: + client, col = _get_collection("licenses") + licenses = list(col.find().sort("created_at", -1)) + for item in licenses: + item["id"] = str(item.get("_id")) + except PyMongoError: + flash("Lizenzen konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + + return render_template("admin_licenses.html", licenses=licenses) + + +@app.route('/admin/invoices', methods=['GET', 'POST']) +@admin_required +def admin_invoices(): + client = None + if request.method == 'POST': + action = _sanitize_text(request.form.get("action") or "", 50) + invoice_id = _sanitize_text(request.form.get("invoice_id") or "", 64) + + try: + client, col = _get_collection("invoices") + + if action == "create": + username = _sanitize_text(request.form.get("username") or "", 80) + period = _sanitize_text(request.form.get("period") or "", 20) + due_date = _sanitize_text(request.form.get("due_date") or "", 20) + status = _sanitize_text(request.form.get("status") or "Offen", 40) + amount_text = _sanitize_text(request.form.get("amount_eur") or "0", 20) + + try: + amount = float(amount_text) + except ValueError: + amount = 0.0 + + if not username or not period: + flash("Bitte Benutzername und Zeitraum angeben.", "error") + return redirect(url_for("admin_invoices")) + + col.insert_one( + { + "username": username, + "invoice_number": f"INV-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}", + "period": period, + "amount_eur": amount, + "status": status, + "due_date": due_date or "2026-12-31", + "created_at": _utc_now_iso(), + } + ) + flash("Rechnung angelegt.", "success") + + elif action == "update" and invoice_id: + amount_text = _sanitize_text(request.form.get("amount_eur") or "0", 20) + try: + amount = float(amount_text) + except ValueError: + amount = 0.0 + + col.update_one( + {"_id": ObjectId(invoice_id)}, + { + "$set": { + "status": _sanitize_text(request.form.get("status") or "Offen", 40), + "due_date": _sanitize_text(request.form.get("due_date") or "", 20), + "amount_eur": amount, + } + }, + ) + flash("Rechnung aktualisiert.", "success") + + elif action == "delete" and invoice_id: + col.delete_one({"_id": ObjectId(invoice_id)}) + flash("Rechnung geloescht.", "success") + else: + flash("Ungueltige Aktion.", "error") + except Exception: + flash("Rechnungsverwaltung fehlgeschlagen.", "error") + finally: + if client: + client.close() + + return redirect(url_for("admin_invoices")) + + invoices = [] + try: + client, col = _get_collection("invoices") + invoices = list(col.find().sort("created_at", -1)) + for item in invoices: + item["id"] = str(item.get("_id")) + except PyMongoError: + flash("Rechnungen konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + + return render_template("admin_invoices.html", invoices=invoices) + + @app.route('/datenschutz') def datenschutz(): return render_template("datenschutz.html") diff --git a/Website/run.sh b/Website/run.sh new file mode 100755 index 0000000..e400c8d --- /dev/null +++ b/Website/run.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Clean up any existing MongoDB repos to avoid conflicts +echo "=== Cleaning up existing MongoDB repositories ===" +sudo rm -f /etc/apt/sources.list.d/mongodb*.list +sudo apt-key del 7F0CEB10 2930ADAE8CAF5059EE73BB4B58712A2291FA4AD5 20691EEC35216C63CAF66CE1656408E390CFB1F5 4B7C549A058F8B6B 2069827F925C2E182330D4D4B5BEA7232F5C6971 E162F504A20CDF15827F718D4B7C549A058F8B6B 9DA31620334BD75D9DCB49F368818C72E52529D4 F5679A222C647C87527C2F8CB00A0BD1E2C63C11 2023-02-15 > /dev/null 2>&1 || true +# Update system packages +echo "=== Updating system packages ===" +sudo apt update || { echo "Failed to update package lists"; exit 1; } +# Add MongoDB repository depending on OS (Ubuntu Server or Linux Mint) +echo "=== Adding MongoDB repository ===" +# Detect OS id from /etc/os-release +OS_ID=$(awk -F= '/^ID=/{print $2}' /etc/os-release | tr -d '"') +# Prefer Ubuntu base codename from /etc/os-release when available +UBUNTU_BASE_CODENAME=$(awk -F= '/^UBUNTU_CODENAME=/{print $2}' /etc/os-release | tr -d '"') +if [ -z "$UBUNTU_BASE_CODENAME" ]; then + UBUNTU_BASE_CODENAME=$(lsb_release -cs 2>/dev/null || awk -F= '/^VERSION_CODENAME=/{print $2}' /etc/os-release | tr -d '"') +fi +if [ "$OS_ID" = "linuxmint" ]; then + # Map Linux Mint codename to Ubuntu base codename when needed + MINT_CODENAME=$(lsb_release -cs 2>/dev/null || awk -F= '/^VERSION_CODENAME=/{print $2}' /etc/os-release | tr -d '"') + if [ -z "$UBUNTU_BASE_CODENAME" ] || [ "$UBUNTU_BASE_CODENAME" = "$MINT_CODENAME" ]; then + case "$MINT_CODENAME" in + xia) UBUNTU_BASE_CODENAME="noble" ;; + vanessa|vera|victoria) UBUNTU_BASE_CODENAME="jammy" ;; + ulyana|ulyssa|uma|una) UBUNTU_BASE_CODENAME="focal" ;; + esac + fi + echo "Detected Linux Mint ($MINT_CODENAME) → using Ubuntu base '$UBUNTU_BASE_CODENAME'" +elif [ "$OS_ID" = "ubuntu" ]; +then + echo "Detected Ubuntu ($UBUNTU_BASE_CODENAME)" +else + echo "Non-Ubuntu/Mint OS detected ($OS_ID). Skipping MongoDB apt setup." + exit 1 +fi +# Select MongoDB series per Ubuntu base codename +case "$UBUNTU_BASE_CODENAME" in + noble|jammy) + MONGO_SERIES="7.0" ;; + focal) + MONGO_SERIES="6.0" ;; + *) + echo "Unknown Ubuntu codename '$UBUNTU_BASE_CODENAME', defaulting to 7.0" + MONGO_SERIES="7.0" ;; +esac +# Use jammy repo path for noble until MongoDB publishes noble (avoid 404) +MONGO_APT_CODENAME="$UBUNTU_BASE_CODENAME" +if [ "$UBUNTU_BASE_CODENAME" = "noble" ]; then + MONGO_APT_CODENAME="jammy" + echo "Using jammy repo path for MongoDB on noble" +fi +# Install repo key and list using series and apt codename +wget -qO - https://www.mongodb.org/static/pgp/server-${MONGO_SERIES}.asc | sudo gpg --dearmor -o /usr/share/keyrings/mongodb-server-${MONGO_SERIES}.gpg +echo "deb [signed-by=/usr/share/keyrings/mongodb-server-${MONGO_SERIES}.gpg arch=amd64,arm64] https://repo.mongodb.org/apt/ubuntu ${MONGO_APT_CODENAME}/mongodb-org/${MONGO_SERIES} multiverse" | \ + sudo tee /etc/apt/sources.list.d/mongodb-org-${MONGO_SERIES}.list +# Install MongoDB +sudo apt-get update || exit 1 +sudo apt-get install -y mongodb-org || exit 1 + +if [[ -n "${CONDA_DEFAULT_ENV:-}" ]] && command -v conda >/dev/null 2>&1; then + conda deactivate || true +fi + +source .venv/bin/activate + +# Do not run apt here because third-party repos in dev containers can fail. +# We only check and provide the install hint if patchelf is missing. +if ! command -v patchelf >/dev/null 2>&1; then + echo "Error: patchelf is required for Nuitka standalone builds on Linux." + echo "Install with: sudo apt update && sudo apt install -y patchelf" + exit 1 +fi + +pip install -U nuitka ordered-set zstandard flask flask-jwt-extended cryptography pyotp qrcode bleach + +python -m nuitka --standalone --follow-imports --include-data-dir=templates=templates --include-data-dir=static=static --include-data-dir=data=data --assume-yes-for-downloads --output-dir=build --remove-output main.py +if [[ -x "./build/main.bin" ]]; then + ./build/main.bin +else + echo "Build step finished but executable not found at ./build/main.bin" + exit 1 +fi \ No newline at end of file diff --git a/Website/templates/admin_chats.html b/Website/templates/admin_chats.html new file mode 100644 index 0000000..876841f --- /dev/null +++ b/Website/templates/admin_chats.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Admin Chat Center{% endblock %} + +{% block content %} +
+

Admin Chat Center

+

Unterhaltungen mit Nutzern einsehen und beantworten.

+
+ +
+ + +
+
+ {% for msg in messages %} +
+ {{ msg.sender }} +

{{ msg.message }}

+ {{ msg.created_at }} +
+ {% else %} +

Keine Nachrichten vorhanden.

+ {% endfor %} +
+ {% if selected_user %} +
+ + + +
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/Website/templates/admin_invoices.html b/Website/templates/admin_invoices.html new file mode 100644 index 0000000..62524c4 --- /dev/null +++ b/Website/templates/admin_invoices.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %}Admin | Rechnungsverwaltung{% endblock %} + +{% block content %} +
+

Rechnungsverwaltung

+

Rechnungen fuer Nutzer erstellen, aktualisieren und loeschen.

+
+ +
+
+

Neue Rechnung

+ + + + + + + +
+ +
+ {% for item in invoices %} +
+

{{ item.invoice_number }}

+

Nutzer: {{ item.username }} | Zeitraum: {{ item.period }}

+

Betrag: {{ '%.2f'|format(item.amount_eur) }} EUR | Status: {{ item.status }} | Faellig: {{ item.due_date }}

+
+ + + + + + +
+
+ + + +
+
+ {% else %} +

Noch keine Rechnungen vorhanden.

+ {% endfor %} +
+
+ + +{% endblock %} diff --git a/Website/templates/admin_licenses.html b/Website/templates/admin_licenses.html new file mode 100644 index 0000000..37fbb1a --- /dev/null +++ b/Website/templates/admin_licenses.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %}Admin | Lizenzverwaltung{% endblock %} + +{% block content %} +
+

Lizenzverwaltung

+

Lizenzen fuer Nutzer anlegen, bearbeiten und loeschen.

+
+ +
+
+

Neue Lizenz

+ + + + + + + +
+ +
+ {% for item in licenses %} +
+

{{ item.username }} - {{ item.school_name }}

+

Key: {{ item.license_key }} | Plan: {{ item.plan }}

+

Status: {{ item.status }} | Gueltig bis: {{ item.valid_until }}

+
+ + + + + + +
+
+ + + +
+
+ {% else %} +

Noch keine Lizenzen vorhanden.

+ {% endfor %} +
+
+ + +{% endblock %} diff --git a/Website/templates/admin_tickets.html b/Website/templates/admin_tickets.html new file mode 100644 index 0000000..e024ee3 --- /dev/null +++ b/Website/templates/admin_tickets.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}Admin | Support Tickets{% endblock %} + +{% block content %} +
+

Support Tickets

+

Ticketstatus verwalten und Antworten hinterlegen.

+
+ +
+ {% for ticket in tickets %} +
+

{{ ticket.title }}

+

Nutzer: {{ ticket.username }} | Prioritaet: {{ ticket.priority }}

+

{{ ticket.description }}

+
+ + + + +
+
+ {% else %} +

Keine Tickets vorhanden.

+ {% endfor %} +
+ + +{% endblock %} diff --git a/Website/templates/admin_users.html b/Website/templates/admin_users.html new file mode 100644 index 0000000..4158fb1 --- /dev/null +++ b/Website/templates/admin_users.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block title %}Admin | Userverwaltung{% endblock %} + +{% block content %} +
+

Userverwaltung

+

Benutzer verwalten, Admin-Rechte vergeben und Konten bereinigen.

+
+ +
+ + + + + + + + + + + {% for user in users %} + + + + + + + {% endfor %} + +
BenutzernameNameRolleAktionen
{{ user.username }}{{ user.display_name }}{{ 'Admin' if user.is_admin else 'Nutzer' }} +
+ {% if not user.is_admin %} +
+ + + +
+ {% else %} +
+ + + +
+ {% endif %} +
+ + + +
+
+
+
+ + +{% endblock %} diff --git a/Website/templates/base.html b/Website/templates/base.html index 92f517e..d2f2070 100644 --- a/Website/templates/base.html +++ b/Website/templates/base.html @@ -25,6 +25,8 @@ --accent: #f28c28; --radius-md: 14px; --shadow-sm: 0 10px 30px rgba(17, 35, 50, 0.08); + --shell-gutter: clamp(12px, 2.2vw, 34px); + --shell-max-width: 100vw; } * { box-sizing: border-box; } @@ -40,16 +42,39 @@ var(--bg-main); } - h1, h2, h3, h4 { + h1 { margin: 0; font-family: "Space Grotesk", system-ui, sans-serif; line-height: 1.08; + font-size: clamp(1.75rem, 4.5vw, 4.2rem); + } + + h2 { + margin: 0; + font-family: "Space Grotesk", system-ui, sans-serif; + line-height: 1.08; + font-size: clamp(1.4rem, 3.2vw, 3rem); + } + + h3 { + margin: 0; + font-family: "Space Grotesk", system-ui, sans-serif; + line-height: 1.08; + font-size: clamp(1.1rem, 2.5vw, 2rem); + } + + h4 { + margin: 0; + font-family: "Space Grotesk", system-ui, sans-serif; + line-height: 1.08; + font-size: clamp(1rem, 1.8vw, 1.5rem); } p { margin: 0; line-height: 1.55; color: var(--text-soft); + font-size: clamp(0.95rem, 1.2vw, 1.1rem); } a { @@ -63,7 +88,7 @@ } .site-shell { - width: min(1240px, calc(100% - 2rem)); + width: min(var(--shell-max-width), calc(100% - (var(--shell-gutter) * 2))); margin: 0 auto; } @@ -134,6 +159,56 @@ background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%); } + .nav-dropdown { + position: relative; + } + + .nav-dropdown > summary { + list-style: none; + cursor: pointer; + } + + .nav-dropdown > summary::-webkit-details-marker { + display: none; + } + + .nav-dropdown > summary::after { + content: "▾"; + margin-left: 0.45rem; + font-size: 0.72rem; + color: #57798f; + } + + .nav-dropdown[open] > summary::after { + content: "▴"; + } + + .dropdown-menu { + position: absolute; + right: 0; + top: calc(100% + 0.45rem); + min-width: 220px; + padding: 0.5rem; + border-radius: 12px; + border: 1px solid #ccdae5; + background: #ffffff; + box-shadow: 0 12px 24px rgba(16, 44, 62, 0.15); + display: grid; + gap: 0.35rem; + z-index: 40; + } + + .dropdown-menu a { + padding: 0.5rem 0.65rem; + border-radius: 8px; + font-weight: 600; + color: #1a4865; + } + + .dropdown-menu a:hover { + background: #f3f8fc; + } + .main-wrap { padding: 2rem 0 3.4rem; } @@ -174,6 +249,12 @@ padding: 0.42rem 0.72rem; font-size: 0.85rem; } + + .dropdown-menu { + right: auto; + left: 0; + min-width: 200px; + } } {% endblock %} @@ -190,11 +271,40 @@