Add admin management features and MongoDB setup scripts

- Created .vscode/settings.json for Python environment configuration.
- Added run.sh script for MongoDB installation and project setup.
- Implemented admin chat interface in admin_chats.html.
- Developed invoice management interface in admin_invoices.html.
- Created license management interface in admin_licenses.html.
- Added support ticket management interface in admin_tickets.html.
- Implemented user management interface in admin_users.html.
- Developed user chat interface in chat.html.
- Created user invoice overview in my_invoices.html.
- Developed user license overview in my_licenses.html.
- Added support ticket creation and tracking in tickets.html.
- Created test.sh script for MongoDB setup.
This commit is contained in:
Maximilian G.
2026-03-23 22:10:13 +00:00
committed by GitHub
parent 283a37638b
commit d00c4e2cb9
18 changed files with 1424 additions and 71 deletions
+1
View File
@@ -2,3 +2,4 @@
__pycache__ __pycache__
Inventarsystem_Lizenz_Verwaltung Inventarsystem_Lizenz_Verwaltung
main.dist main.dist
.mongo-data
+9
View File
@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": "Website/.venv",
"envManager": "ms-python.python:venv",
"packageManager": "ms-python.python:pip"
}
]
}
+4 -4
View File
@@ -40,10 +40,10 @@ if settings_path.is_file():
version_file.write_text(f"{version}\n", encoding="utf-8") version_file.write_text(f"{version}\n", encoding="utf-8")
print(f"Prepared {version_file} with version: {version}") print(f"Prepared {version_file} with version: {version}")
PY 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 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 "./Inventarsystem_Lizenz_Verwaltung/main.bin" ]]; then if [[ -x "./build/main.bin" ]]; then
./Inventarsystem_Lizenz_Verwaltung/main.bin ./build/main.bin
else 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 exit 1
fi fi
+28
View File
@@ -12,5 +12,33 @@
"response": "Es geht leider nicht um die von ihnen angefragte Uhrzeit, bitte versuchen sie es zwei Tage sp\u00e4ter", "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_at": "2026-03-23T16:06:52Z",
"responded_by": "Aiirondev" "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"
} }
] ]
+548 -19
View File
@@ -9,6 +9,11 @@ from functools import wraps
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import bleach import bleach
from markupsafe import escape 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 = Flask(__name__)
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4s" app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4s"
@@ -35,12 +40,102 @@ DATA_DIR = BASE_DIR / "data"
USERS_FILE = DATA_DIR / "users.json" USERS_FILE = DATA_DIR / "users.json"
APPOINTMENTS_FILE = DATA_DIR / "appointments.json" APPOINTMENTS_FILE = DATA_DIR / "appointments.json"
POSTS_FILE = DATA_DIR / "posts.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: def _issue_access_token() -> str:
return create_access_token(identity="license-validation-client") 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: def _ensure_data_files() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
if not USERS_FILE.exists(): if not USERS_FILE.exists():
@@ -93,13 +188,27 @@ def _validate_username(username: str) -> bool:
def _find_user(username: str): def _find_user(username: str):
username_key = (username or "").strip().lower() username_key = (username or "").strip()
if not username_key: if not username_key:
return None return None
users = _read_json(USERS_FILE)
for entry in users: try:
if entry.get("username", "").lower() == username_key: # First try an exact lookup using the original username.
return entry 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 return None
@@ -154,13 +263,20 @@ def login():
flash('Bitte Benutzername und Passwort eingeben.', 'error') flash('Bitte Benutzername und Passwort eingeben.', 'error')
return redirect(url_for('login')) 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['username'] = stored_user.get("username")
session['display_name'] = stored_user.get("display_name") or 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['is_admin'] = stored_user.get("is_admin", False)
session['access_token'] = _issue_access_token() session['access_token'] = _issue_access_token()
_ensure_user_license(session['username'], session['display_name'])
_ensure_user_invoice(session['username'])
if request.is_json: if request.is_json:
return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200 return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200
@@ -205,18 +321,18 @@ def register():
display_name = _sanitize_text(display_name, 100) display_name = _sanitize_text(display_name, 100)
users = _read_json(USERS_FILE) try:
is_admin = len(users) == 0 existing_users = user_store.get_all_users() or []
users.append( is_first_user = len(existing_users) == 0
{ if not user_store.add_user(username, password, display_name, ""):
"username": username, flash("Benutzer konnte nicht erstellt werden.", "error")
"display_name": display_name, return redirect(url_for("register"))
"password_hash": generate_password_hash(password), if is_first_user:
"is_admin": is_admin, user_store.make_admin(username)
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", except Exception:
} flash("MongoDB ist derzeit nicht erreichbar.", "error")
) return redirect(url_for("register"))
_write_json(USERS_FILE, users)
flash("Registrierung erfolgreich. Bitte jetzt einloggen.", "success") flash("Registrierung erfolgreich. Bitte jetzt einloggen.", "success")
return redirect(url_for("login")) return redirect(url_for("login"))
@@ -441,6 +557,419 @@ def blog_post(post_id):
return render_template("blog_post.html", post=post) 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') @app.route('/datenschutz')
def datenschutz(): def datenschutz():
return render_template("datenschutz.html") return render_template("datenschutz.html")
Executable
+84
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Admin Chat Center{% endblock %}
{% block content %}
<section class="panel">
<h1>Admin Chat Center</h1>
<p>Unterhaltungen mit Nutzern einsehen und beantworten.</p>
</section>
<section class="layout">
<aside class="users">
<h3>Unterhaltungen</h3>
{% for username in conversations %}
<a class="user-link {{ 'active' if username == selected_user else '' }}" href="{{ url_for('admin_chats', username=username) }}">{{ username }}</a>
{% else %}
<p>Keine aktiven Unterhaltungen.</p>
{% endfor %}
</aside>
<div class="chat-panel">
<div class="messages">
{% for msg in messages %}
<div class="message {{ msg.sender_role }}">
<strong>{{ msg.sender }}</strong>
<p>{{ msg.message }}</p>
<small>{{ msg.created_at }}</small>
</div>
{% else %}
<p>Keine Nachrichten vorhanden.</p>
{% endfor %}
</div>
{% if selected_user %}
<form method="post" class="composer">
<input type="hidden" name="username" value="{{ selected_user }}">
<textarea name="message" rows="3" required placeholder="Antwort an {{ selected_user }}..."></textarea>
<button type="submit">Antwort senden</button>
</form>
{% endif %}
</div>
</section>
<style>
.panel { margin-bottom: 1rem; }
.layout { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; }
.users, .chat-panel { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.user-link { display: block; padding: 0.5rem; border-radius: 10px; border: 1px solid transparent; margin-bottom: 0.45rem; }
.user-link:hover, .user-link.active { border-color: #bcd0de; background: #f4f9fd; }
.messages { display: grid; gap: 0.6rem; margin-bottom: 0.8rem; max-height: 460px; overflow: auto; }
.message { border: 1px solid #e4edf3; border-radius: 10px; padding: 0.65rem; }
.message.admin { background: #f2f8ff; }
.message.user { background: #fcfdff; }
.composer { display: grid; gap: 0.6rem; }
textarea { width: 100%; border: 1px solid #c9d8e3; border-radius: 12px; padding: 0.7rem; font: inherit; }
button { width: fit-content; border: 1px solid #0a4c74; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); color: #fff; padding: 0.5rem 1rem; border-radius: 999px; font-weight: 700; }
@media (max-width: 960px) { .layout { grid-template-columns: 1fr; } }
</style>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Admin | Rechnungsverwaltung{% endblock %}
{% block content %}
<section class="panel">
<h1>Rechnungsverwaltung</h1>
<p>Rechnungen fuer Nutzer erstellen, aktualisieren und loeschen.</p>
</section>
<section class="layout">
<form method="post" class="form-card">
<h3>Neue Rechnung</h3>
<input type="hidden" name="action" value="create">
<input type="text" name="username" placeholder="Benutzername" required>
<input type="text" name="period" placeholder="Zeitraum (MM/YYYY)" required>
<input type="text" name="amount_eur" placeholder="Betrag in EUR" required>
<input type="text" name="due_date" placeholder="Faelligkeit (YYYY-MM-DD)">
<input type="text" name="status" placeholder="Status (Offen, Bezahlt)">
<button type="submit">Rechnung anlegen</button>
</form>
<div class="list">
{% for item in invoices %}
<article class="entry">
<h3>{{ item.invoice_number }}</h3>
<p><strong>Nutzer:</strong> {{ item.username }} | <strong>Zeitraum:</strong> {{ item.period }}</p>
<p><strong>Betrag:</strong> {{ '%.2f'|format(item.amount_eur) }} EUR | <strong>Status:</strong> {{ item.status }} | <strong>Faellig:</strong> {{ item.due_date }}</p>
<form method="post" class="inline-form">
<input type="hidden" name="action" value="update">
<input type="hidden" name="invoice_id" value="{{ item.id }}">
<input type="text" name="amount_eur" value="{{ item.amount_eur }}" placeholder="Betrag">
<input type="text" name="due_date" value="{{ item.due_date }}" placeholder="Faelligkeit">
<input type="text" name="status" value="{{ item.status }}" placeholder="Status">
<button type="submit">Aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="invoice_id" value="{{ item.id }}">
<button type="submit" class="danger">Loeschen</button>
</form>
</article>
{% else %}
<article class="entry"><p>Noch keine Rechnungen vorhanden.</p></article>
{% endfor %}
</div>
</section>
<style>
.panel { margin-bottom: 1rem; }
.layout { display: grid; grid-template-columns: 320px 1fr; gap: 1rem; }
.form-card, .entry { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.form-card { display: grid; gap: 0.6rem; align-self: start; }
.list { display: grid; gap: 0.8rem; }
.inline-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.45rem; margin-top: 0.6rem; }
input { width: 100%; border: 1px solid #c9d8e3; border-radius: 10px; padding: 0.52rem; font: inherit; }
button { border: 1px solid #0a4c74; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); color: #fff; padding: 0.45rem 0.8rem; border-radius: 999px; font-weight: 700; }
button.danger { border-color: #d04a49; background: #fff; color: #922e2e; margin-top: 0.55rem; }
@media (max-width: 980px) { .layout { grid-template-columns: 1fr; } .inline-form { grid-template-columns: 1fr; } }
</style>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Admin | Lizenzverwaltung{% endblock %}
{% block content %}
<section class="panel">
<h1>Lizenzverwaltung</h1>
<p>Lizenzen fuer Nutzer anlegen, bearbeiten und loeschen.</p>
</section>
<section class="layout">
<form method="post" class="form-card">
<h3>Neue Lizenz</h3>
<input type="hidden" name="action" value="create">
<input type="text" name="username" placeholder="Benutzername" required>
<input type="text" name="school_name" placeholder="Schulname" required>
<input type="text" name="plan" placeholder="Plan (z. B. Standard)">
<input type="text" name="status" placeholder="Status (Aktiv, Pausiert)">
<input type="text" name="valid_until" placeholder="Gueltig bis (YYYY-MM-DD)">
<button type="submit">Lizenz anlegen</button>
</form>
<div class="list">
{% for item in licenses %}
<article class="entry">
<h3>{{ item.username }} - {{ item.school_name }}</h3>
<p><strong>Key:</strong> {{ item.license_key }} | <strong>Plan:</strong> {{ item.plan }}</p>
<p><strong>Status:</strong> {{ item.status }} | <strong>Gueltig bis:</strong> {{ item.valid_until }}</p>
<form method="post" class="inline-form">
<input type="hidden" name="action" value="update">
<input type="hidden" name="license_id" value="{{ item.id }}">
<input type="text" name="plan" value="{{ item.plan }}" placeholder="Plan">
<input type="text" name="status" value="{{ item.status }}" placeholder="Status">
<input type="text" name="valid_until" value="{{ item.valid_until }}" placeholder="Gueltig bis">
<button type="submit">Aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="license_id" value="{{ item.id }}">
<button type="submit" class="danger">Loeschen</button>
</form>
</article>
{% else %}
<article class="entry"><p>Noch keine Lizenzen vorhanden.</p></article>
{% endfor %}
</div>
</section>
<style>
.panel { margin-bottom: 1rem; }
.layout { display: grid; grid-template-columns: 320px 1fr; gap: 1rem; }
.form-card, .entry { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.form-card { display: grid; gap: 0.6rem; align-self: start; }
.list { display: grid; gap: 0.8rem; }
.inline-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.45rem; margin-top: 0.6rem; }
input { width: 100%; border: 1px solid #c9d8e3; border-radius: 10px; padding: 0.52rem; font: inherit; }
button { border: 1px solid #0a4c74; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); color: #fff; padding: 0.45rem 0.8rem; border-radius: 999px; font-weight: 700; }
button.danger { border-color: #d04a49; background: #fff; color: #922e2e; margin-top: 0.55rem; }
@media (max-width: 980px) { .layout { grid-template-columns: 1fr; } .inline-form { grid-template-columns: 1fr; } }
</style>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Admin | Support Tickets{% endblock %}
{% block content %}
<section class="panel">
<h1>Support Tickets</h1>
<p>Ticketstatus verwalten und Antworten hinterlegen.</p>
</section>
<section class="tickets">
{% for ticket in tickets %}
<article class="ticket">
<h3>{{ ticket.title }}</h3>
<p><strong>Nutzer:</strong> {{ ticket.username }} | <strong>Prioritaet:</strong> {{ ticket.priority }}</p>
<p>{{ ticket.description }}</p>
<form method="post" class="update-form">
<input type="hidden" name="ticket_id" value="{{ ticket.id }}">
<select name="status">
<option value="Offen" {{ 'selected' if ticket.status == 'Offen' else '' }}>Offen</option>
<option value="In Bearbeitung" {{ 'selected' if ticket.status == 'In Bearbeitung' else '' }}>In Bearbeitung</option>
<option value="Geloest" {{ 'selected' if ticket.status == 'Geloest' else '' }}>Geloest</option>
</select>
<textarea name="admin_response" rows="3" placeholder="Antwort an Nutzer">{{ ticket.admin_response }}</textarea>
<button type="submit">Aktualisieren</button>
</form>
</article>
{% else %}
<article class="ticket"><p>Keine Tickets vorhanden.</p></article>
{% endfor %}
</section>
<style>
.panel { margin-bottom: 1rem; }
.tickets { display: grid; gap: 0.8rem; }
.ticket { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.ticket p { margin-top: 0.3rem; }
.update-form { margin-top: 0.6rem; display: grid; gap: 0.55rem; }
select, textarea { width: 100%; border: 1px solid #c9d8e3; border-radius: 10px; padding: 0.55rem; font: inherit; }
button { width: fit-content; border: 1px solid #0a4c74; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); color: #fff; padding: 0.5rem 1rem; border-radius: 999px; font-weight: 700; }
</style>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Admin | Userverwaltung{% endblock %}
{% block content %}
<section class="panel">
<h1>Userverwaltung</h1>
<p>Benutzer verwalten, Admin-Rechte vergeben und Konten bereinigen.</p>
</section>
<section class="table-wrap">
<table>
<thead>
<tr>
<th>Benutzername</th>
<th>Name</th>
<th>Rolle</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.display_name }}</td>
<td>{{ 'Admin' if user.is_admin else 'Nutzer' }}</td>
<td>
<div class="actions">
{% if not user.is_admin %}
<form method="post">
<input type="hidden" name="username" value="{{ user.username }}">
<input type="hidden" name="action" value="make_admin">
<button type="submit">Zu Admin</button>
</form>
{% else %}
<form method="post">
<input type="hidden" name="username" value="{{ user.username }}">
<input type="hidden" name="action" value="remove_admin">
<button type="submit" class="warn">Admin entziehen</button>
</form>
{% endif %}
<form method="post">
<input type="hidden" name="username" value="{{ user.username }}">
<input type="hidden" name="action" value="delete_user">
<button type="submit" class="danger">Loeschen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<style>
.panel { margin-bottom: 1rem; }
.table-wrap { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.7rem; border-bottom: 1px solid #e8eef2; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
button { border: 1px solid #bcd0de; background: #fff; padding: 0.4rem 0.65rem; border-radius: 999px; font-weight: 700; cursor: pointer; }
button.warn { border-color: #efcc95; }
button.danger { border-color: #f0b7b7; color: #8d2929; }
</style>
{% endblock %}
+114 -4
View File
@@ -25,6 +25,8 @@
--accent: #f28c28; --accent: #f28c28;
--radius-md: 14px; --radius-md: 14px;
--shadow-sm: 0 10px 30px rgba(17, 35, 50, 0.08); --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; } * { box-sizing: border-box; }
@@ -40,16 +42,39 @@
var(--bg-main); var(--bg-main);
} }
h1, h2, h3, h4 { h1 {
margin: 0; margin: 0;
font-family: "Space Grotesk", system-ui, sans-serif; font-family: "Space Grotesk", system-ui, sans-serif;
line-height: 1.08; 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 { p {
margin: 0; margin: 0;
line-height: 1.55; line-height: 1.55;
color: var(--text-soft); color: var(--text-soft);
font-size: clamp(0.95rem, 1.2vw, 1.1rem);
} }
a { a {
@@ -63,7 +88,7 @@
} }
.site-shell { .site-shell {
width: min(1240px, calc(100% - 2rem)); width: min(var(--shell-max-width), calc(100% - (var(--shell-gutter) * 2)));
margin: 0 auto; margin: 0 auto;
} }
@@ -134,6 +159,56 @@
background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%); 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 { .main-wrap {
padding: 2rem 0 3.4rem; padding: 2rem 0 3.4rem;
} }
@@ -174,6 +249,12 @@
padding: 0.42rem 0.72rem; padding: 0.42rem 0.72rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.dropdown-menu {
right: auto;
left: 0;
min-width: 200px;
}
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -190,11 +271,40 @@
</div> </div>
<div class="nav-actions"> <div class="nav-actions">
{% if 'username' in session %} {% if 'username' in session %}
<a class="nav-btn" href="{{ url_for('blog') }}">Blog</a>
<a class="nav-btn" href="{{ url_for('appointments') }}">Termin anfragen</a> <a class="nav-btn" href="{{ url_for('appointments') }}">Termin anfragen</a>
<details class="nav-dropdown">
<summary class="nav-btn">Nutzerbereich</summary>
<div class="dropdown-menu">
<a href="{{ url_for('my_licenses') }}">Meine Lizenzen</a>
<a href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
<a href="{{ url_for('user_chat') }}">Chat mit Admin</a>
<a href="{{ url_for('user_tickets') }}">Support Tickets</a>
</div>
</details>
<details class="nav-dropdown">
<summary class="nav-btn">Inhalte</summary>
<div class="dropdown-menu">
<a href="{{ url_for('blog') }}">Blog</a>
</div>
</details>
{% if session.get('is_admin') %} {% if session.get('is_admin') %}
<a class="nav-btn" href="{{ url_for('admin_dashboard') }}">Admin</a> <details class="nav-dropdown">
<summary class="nav-btn">Admin</summary>
<div class="dropdown-menu">
<a href="{{ url_for('admin_dashboard') }}">Dashboard</a>
<a href="{{ url_for('admin_users') }}">Userverwaltung</a>
<a href="{{ url_for('admin_licenses') }}">Lizenzverwaltung</a>
<a href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a>
<a href="{{ url_for('admin_chats') }}">Admin-Chat</a>
<a href="{{ url_for('admin_tickets') }}">Support-Center</a>
<a href="{{ url_for('admin_blog') }}">Blog verwalten</a>
</div>
</details>
{% endif %} {% endif %}
<a class="nav-btn" href="{{ url_for('logout') }}">Logout</a> <a class="nav-btn" href="{{ url_for('logout') }}">Logout</a>
{% else %} {% else %}
<a class="nav-btn" href="{{ url_for('blog') }}">Blog</a> <a class="nav-btn" href="{{ url_for('blog') }}">Blog</a>
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Chat mit Admin{% endblock %}
{% block content %}
<section class="panel">
<h1>Chat mit den Administratoren</h1>
<p>Stellen Sie Fragen direkt im Chat. Antworten erscheinen in derselben Unterhaltung.</p>
</section>
<section class="chat-box">
<div class="messages">
{% for msg in messages %}
<div class="message {{ 'admin' if msg.sender_role == 'admin' else 'user' }}">
<strong>{{ msg.sender }}</strong>
<p>{{ msg.message }}</p>
<small>{{ msg.created_at }}</small>
</div>
{% else %}
<p>Noch keine Nachrichten vorhanden.</p>
{% endfor %}
</div>
<form method="post" class="composer">
<textarea name="message" rows="3" placeholder="Ihre Nachricht..." required></textarea>
<button type="submit">Senden</button>
</form>
</section>
<style>
.panel { margin-bottom: 1rem; }
.chat-box { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.messages { display: grid; gap: 0.6rem; margin-bottom: 0.8rem; max-height: 440px; overflow: auto; }
.message { border: 1px solid #e4edf3; border-radius: 10px; padding: 0.65rem; background: #f9fcff; }
.message.admin { background: #f2f8ff; border-color: #c8def1; }
.message.user { background: #fcfdff; }
.message p { margin-top: 0.3rem; }
.message small { color: #6f8798; }
.composer { display: grid; gap: 0.6rem; }
textarea { width: 100%; border: 1px solid #c9d8e3; border-radius: 12px; padding: 0.7rem; font: inherit; }
button { width: fit-content; border: 1px solid #0a4c74; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); color: #fff; padding: 0.5rem 1rem; border-radius: 999px; font-weight: 700; }
</style>
{% endblock %}
+113 -42
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}IT Loesungen | Modern{% endblock %} {% block title %}Inventarlösungen{% endblock %}
{% block head %} {% block head %}
{{ super() }} {{ super() }}
@@ -37,7 +37,7 @@
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
padding: 0.3rem 0.65rem; padding: 0.3rem 0.65rem;
border-radius: 999px; border-radius: 999px;
font-size: 0.75rem; font-size: clamp(0.7rem, 0.9vw, 0.85rem);
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@@ -46,7 +46,7 @@
} }
.hero h1 { .hero h1 {
font-size: clamp(2rem, 5.1vw, 3.4rem); font-size: clamp(2rem, 5.5vw, 4rem);
color: #06293e; color: #06293e;
text-wrap: balance; text-wrap: balance;
} }
@@ -54,7 +54,7 @@
.hero p { .hero p {
max-width: 56ch; max-width: 56ch;
margin-top: 1.1rem; margin-top: 1.1rem;
font-size: 1.06rem; font-size: clamp(0.95rem, 1.4vw, 1.2rem);
} }
.cta-row { .cta-row {
@@ -101,13 +101,13 @@
} }
.hero-card h3 { .hero-card h3 {
font-size: 1.15rem; font-size: clamp(1rem, 2vw, 1.4rem);
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
} }
.hero-card p { .hero-card p {
color: #b6d6ea; color: #b6d6ea;
font-size: 0.96rem; font-size: clamp(0.9rem, 1.1vw, 1.05rem);
margin: 0; margin: 0;
} }
@@ -148,7 +148,7 @@
} }
.section h2 { .section h2 {
font-size: clamp(1.4rem, 2.5vw, 2rem); font-size: clamp(1.5rem, 3.2vw, 2.8rem);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #0f334b; color: #0f334b;
} }
@@ -168,13 +168,13 @@
} }
.card h3 { .card h3 {
font-size: 1.07rem; font-size: clamp(1rem, 1.8vw, 1.3rem);
color: #113c58; color: #113c58;
margin-bottom: 0.45rem; margin-bottom: 0.45rem;
} }
.card p { .card p {
font-size: 0.96rem; font-size: clamp(0.9rem, 1.1vw, 1rem);
} }
.project-list { .project-list {
@@ -198,10 +198,11 @@
display: block; display: block;
color: #0b3753; color: #0b3753;
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
font-size: clamp(1rem, 1.5vw, 1.15rem);
} }
.project span { .project span {
font-size: 0.88rem; font-size: clamp(0.8rem, 0.95vw, 0.95rem);
color: #4a6579; color: #4a6579;
border: 1px solid #bfd0dd; border: 1px solid #bfd0dd;
padding: 0.2rem 0.55rem; padding: 0.2rem 0.55rem;
@@ -227,12 +228,13 @@
.step strong { .step strong {
display: block; display: block;
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
font-size: 0.98rem; font-size: clamp(0.9rem, 1.2vw, 1.1rem);
color: #0f3650; color: #0f3650;
} }
.step small { .step small {
color: #5f7b8f; color: #5f7b8f;
font-size: clamp(0.8rem, 0.9vw, 0.95rem);
} }
.team-note { .team-note {
@@ -260,12 +262,13 @@
.contact h2 { .contact h2 {
color: #ffffff; color: #ffffff;
font-size: clamp(1.25rem, 2.7vw, 2rem); font-size: clamp(1.4rem, 3.2vw, 2.8rem);
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
.contact p { .contact p {
color: #b9d8ec; color: #b9d8ec;
font-size: clamp(0.95rem, 1.2vw, 1.1rem);
} }
.contact .btn-secondary { .contact .btn-secondary {
@@ -284,6 +287,64 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.partnership {
margin-top: 2.2rem;
padding: 2.5rem;
border-radius: 18px;
background: linear-gradient(135deg, #f0f7fc 0%, #e6effa 100%);
border: 2px solid var(--brand);
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 2rem;
align-items: center;
}
.partnership-icon {
font-size: clamp(4rem, 8vw, 6rem);
text-align: center;
line-height: 1;
}
.partnership-content h3 {
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
color: var(--brand);
margin-bottom: 0.8rem;
}
.partnership-content p {
margin-bottom: 0.8rem;
color: var(--text-soft);
font-size: clamp(0.95rem, 1.1vw, 1.05rem);
}
.partnership-badges {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1rem;
}
.badge {
display: inline-block;
padding: 0.4rem 0.85rem;
border-radius: 999px;
background: var(--brand);
color: #ffffff;
font-size: clamp(0.8rem, 0.95vw, 0.95rem);
font-weight: 600;
}
@media (max-width: 700px) {
.partnership {
grid-template-columns: 1fr;
padding: 1.8rem;
}
.partnership-icon {
font-size: clamp(3rem, 6vw, 4rem);
}
}
@keyframes rise { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
@@ -341,12 +402,10 @@
{% block content %} {% block content %}
<section class="hero" id="top"> <section class="hero" id="top">
<div> <div>
<span class="eyebrow">IT Loesungen mit Haltung</span> <span class="eyebrow">IT Lösungen mit Haltung</span>
<h1>Zuhören. Verstehen. Planen. Realisieren.</h1> <h1>Zuhören. Verstehen. Planen. Realisieren.</h1>
<p> <p>
Wir unterstuetzen Unternehmen ganzheitlich bei Enterprise-Architektur, Wir sind spezialisiert auf Schulen aller Art und machen Schulinventur endlich einfach: Unser DSGVO-konformes Inventarsystem sorgt für klare Ordnung, volle Transparenz und schnelle Abläufe im Schulalltag. Jede Lösung wird individuell für Ihre Einrichtung entwickelt, mit passgenauen Features und einer besonders schnellen, unkomplizierten Inbetriebnahme.
Projektmanagement und IT-Betrieb. Die Anforderung bleibt im Mittelpunkt,
die Umsetzung wird klar, messbar und belastbar.
</p> </p>
<div class="cta-row"> <div class="cta-row">
{% if 'username' in session %} {% if 'username' in session %}
@@ -359,7 +418,7 @@
</div> </div>
</div> </div>
<aside class="hero-card" aria-label="Kurzfakten"> <aside class="hero-card" aria-label="Kurzfakten">
<h3>IT Projekte mit Substanz</h3> <h3>Inventur mit Verstand</h3>
<p> <p>
Von Infrastruktur bis Governance: Wir verbinden konzeptionelle Tiefe mit Von Infrastruktur bis Governance: Wir verbinden konzeptionelle Tiefe mit
pragmatischer Umsetzung im Tagesgeschaeft. pragmatischer Umsetzung im Tagesgeschaeft.
@@ -372,56 +431,72 @@
</aside> </aside>
</section> </section>
<section class="partnership" id="entwicklung">
<div class="partnership-icon">🤝</div>
<div class="partnership-content">
<h3>Entwickelt mit Schulen für Schulen</h3>
<p>
Unser Inventarsystem wurde nicht theoretisch konzipiert, sondern in enger Zusammenarbeit mit Einzelnen Schulen entwickelt. Wir verstehen die täglichen Herausforderungen im Schulbetrieb und entwickeln Lösungen, die wirklich einen Vorteil für den Schulaltag bringen.
</p>
<div class="partnership-badges">
<span class="badge">✓ Schulgetestet</span>
<span class="badge">✓ Praxisorientiert</span>
<span class="badge">✓ DSGVO-konform</span>
<span class="badge">✓ Einfach zu bedienen</span>
</div>
</div>
</section>
<section class="section" id="leistungen" style="animation-delay: 100ms;"> <section class="section" id="leistungen" style="animation-delay: 100ms;">
<h2>Dienstleistungen</h2> <h2>Leistungsbereiche</h2>
<p>Modulare Leistungen, individuell kombiniert fuer Ihre IT-Roadmap.</p> <p>Klare Gliederung in Konnektivitaet, Inventarverwaltung und laufenden Betrieb.</p>
<div class="section-grid"> <div class="section-grid">
<article class="card"> <article class="card">
<h3>Support und Service</h3> <h3>Konnektivitaet und Netzwerk</h3>
<p>Betriebskritische Systeme stabil halten, proaktiv ueberwachen und schnell reagieren.</p> <p>Stabile Anbindung Ihrer Schulstandorte, saubere Netzstruktur und sichere Erreichbarkeit aller relevanten Systeme.</p>
</article> </article>
<article class="card"> <article class="card">
<h3>Optimierung und Tuning</h3> <h3>Inventar und Lizenzmanagement</h3>
<p>Strukturen, Kosten und Performance datenbasiert verbessern statt nur Symptome behandeln.</p> <p>Zentrale Verwaltung von Geraeten und Lizenzen mit schneller Uebersicht, nachvollziehbaren Prozessen und klaren Zustaendigkeiten.</p>
</article> </article>
<article class="card"> <article class="card">
<h3>Health Check und Verify</h3> <h3>Support, Updates und Beratung</h3>
<p>Technische Due-Diligence, klare Handlungsplaene und belastbare Priorisierung.</p> <p>Persoenliche Ansprechpartner, kurze Reaktionszeiten und planbare Updates fuer einen zuverlaessigen Betrieb im Schulalltag.</p>
</article> </article>
</div> </div>
</section> </section>
<section class="section" id="projekte" style="animation-delay: 180ms;"> <section class="section" id="projekte" style="animation-delay: 180ms;">
<h2>Projektfelder</h2> <h2>Module im Ueberblick</h2>
<p>Ausgewählte Schwerpunkte aus laufenden und abgeschlossenen Kundenprojekten.</p> <p>Alle Kernbereiche fuer Schulen in einer strukturierten Plattform: Konnektivitaet, Sicherheit und Betrieb.</p>
<div class="project-list"> <div class="project-list">
<div class="project"> <div class="project">
<div> <div>
<b>Virtualisierung und Hybrid-Cloud</b> <b>Konnektivitaet</b>
Migration in wartbare Plattformen mit planbaren Betriebskosten. Sichere Standortanbindung, stabile Verbindungen und klare Netzwerkstruktur fuer einen reibungslosen Zugriff auf alle Inventardaten.
</div> </div>
<span>Cloud Computing</span> <span>Netzwerk</span>
</div> </div>
<div class="project"> <div class="project">
<div> <div>
<b>Netzwerk und Sicherheit</b> <b>Sicherheit und DSGVO</b>
Segmentierung, Monitoring und Security-Standards auf Enterprise-Niveau. Datenschutz nach DSGVO, automatisierte Sicherheitspruefungen und klare Compliance-Standards fuer den Einsatz im oeffentlichen Bereich.
</div> </div>
<span>Security</span> <span>Security</span>
</div> </div>
<div class="project"> <div class="project">
<div> <div>
<b>Projektmanagement und EAM</b> <b>Betrieb und Updates</b>
Governance, Architekturprinzipien und Umsetzung in einer Linie. Regelmaessige Updates, schnelle Stoerungsbehebung und laufende Optimierung sorgen fuer einen dauerhaft stabilen Schulbetrieb.
</div> </div>
<span>Transformation</span> <span>Updates</span>
</div> </div>
</div> </div>
</section> </section>
<section class="section" id="team" style="animation-delay: 260ms;"> <section class="section" id="team" style="animation-delay: 260ms;">
<h2>Unsere Arbeitsweise</h2> <h2>Zusammenarbeit</h2>
<p>Klassische Beratungsdisziplin trifft auf moderne Delivery-Kultur.</p> <p>Strukturiert, nachvollziehbar und mit festen Ansprechpartnern von der Analyse bis zum Betrieb.</p>
<div class="process" aria-label="Prozessschritte"> <div class="process" aria-label="Prozessschritte">
<div class="step"><strong>Zuhoeren</strong><small>Anforderungen erfassen</small></div> <div class="step"><strong>Zuhoeren</strong><small>Anforderungen erfassen</small></div>
<div class="step"><strong>Verstehen</strong><small>Kontext analysieren</small></div> <div class="step"><strong>Verstehen</strong><small>Kontext analysieren</small></div>
@@ -430,16 +505,12 @@
<div class="step"><strong>Realisieren</strong><small>Loesung liefern</small></div> <div class="step"><strong>Realisieren</strong><small>Loesung liefern</small></div>
<div class="step"><strong>Unterstuetzen</strong><small>Betrieb absichern</small></div> <div class="step"><strong>Unterstuetzen</strong><small>Betrieb absichern</small></div>
</div> </div>
<p class="team-note">
Aktuell verfuegbar: Ein IT-Infrastruktur-Architekt und ein IT-Governance-Experte
mit technischem Hintergrund fuer neue Aufgaben.
</p>
</section> </section>
<section class="contact" id="kontakt"> <section class="contact" id="kontakt">
<div> <div>
<h2>Bereit fuer das naechste IT-Projekt?</h2> <h2>Bereit fuer das naechste IT-Projekt?</h2>
<p>In einem kurzen Erstgespraech klaeren wir Zielbild, Risiken und realistische Umsetzungsschritte.</p> <p>In einem kurzen Erstgespräch klären wir gemeinsam Zielbild, Risiken und realistische Umsetzungsschritte. Erstellen Sie dafür einfach ein Konto über den Button oben rechts und buchen Sie anschließend über die Kalenderfunktion einen Termin mit Ihrem Schulnamen und dem Betreff „Erstgespräch“. Alternativ können Sie uns jederzeit per E-Mail kontaktieren oder unseren Website-Chat nutzen.</p>
</div> </div>
{% if 'username' in session %} {% if 'username' in session %}
<a class="btn btn-secondary" href="{{ url_for('appointments') }}">Termin anfragen</a> <a class="btn btn-secondary" href="{{ url_for('appointments') }}">Termin anfragen</a>
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Meine Rechnungen{% endblock %}
{% block content %}
<section class="panel">
<h1>Rechnungseinsicht</h1>
<p>Alle Rechnungen im Ueberblick.</p>
</section>
<section class="table-wrap">
<table>
<thead>
<tr>
<th>Rechnungsnummer</th>
<th>Zeitraum</th>
<th>Betrag</th>
<th>Faelligkeit</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td>{{ invoice.invoice_number }}</td>
<td>{{ invoice.period }}</td>
<td>{{ '%.2f'|format(invoice.amount_eur) }} EUR</td>
<td>{{ invoice.due_date }}</td>
<td>{{ invoice.status }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">Noch keine Rechnungen vorhanden.</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<style>
.panel { margin-bottom: 1rem; }
.table-wrap { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.7rem; border-bottom: 1px solid #e8eef2; }
</style>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Meine Lizenzen{% endblock %}
{% block content %}
<section class="panel">
<h1>Meine Lizenzen</h1>
<p>Hier sehen Sie Ihre aktiven und abgelaufenen Lizenzinformationen.</p>
</section>
<section class="grid">
{% if licenses %}
{% for item in licenses %}
<article class="card">
<h3>{{ item.plan }} Lizenz</h3>
<p><strong>Schule:</strong> {{ item.school_name }}</p>
<p><strong>Lizenzschluessel:</strong> {{ item.license_key }}</p>
<p><strong>Status:</strong> {{ item.status }}</p>
<p><strong>Gueltig bis:</strong> {{ item.valid_until }}</p>
</article>
{% endfor %}
{% else %}
<article class="card">
<h3>Keine Lizenzen vorhanden</h3>
<p>Aktuell sind noch keine Lizenzen hinterlegt.</p>
</article>
{% endif %}
</section>
<style>
.panel { margin-bottom: 1rem; }
.grid { display: grid; gap: 0.8rem; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.card { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.card p { margin-top: 0.35rem; }
</style>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Support Tickets{% endblock %}
{% block content %}
<section class="panel">
<h1>Support Tickets</h1>
<p>Erstellen und verfolgen Sie Ihre Supportanfragen.</p>
</section>
<section class="ticket-layout">
<form method="post" class="ticket-form">
<h3>Neues Ticket</h3>
<input type="text" name="title" placeholder="Titel" required>
<select name="priority">
<option>Normal</option>
<option>Hoch</option>
<option>Kritisch</option>
</select>
<textarea name="description" rows="5" placeholder="Beschreibung" required></textarea>
<button type="submit">Ticket erstellen</button>
</form>
<div class="tickets">
{% for item in tickets %}
<article class="ticket">
<h3>{{ item.title }}</h3>
<p><strong>Status:</strong> {{ item.status }} | <strong>Prioritaet:</strong> {{ item.priority }}</p>
<p>{{ item.description }}</p>
{% if item.admin_response %}
<p><strong>Admin Antwort:</strong> {{ item.admin_response }}</p>
{% endif %}
<small>{{ item.created_at }}</small>
</article>
{% else %}
<article class="ticket"><p>Keine Tickets vorhanden.</p></article>
{% endfor %}
</div>
</section>
<style>
.panel { margin-bottom: 1rem; }
.ticket-layout { display: grid; grid-template-columns: 320px 1fr; gap: 1rem; }
.ticket-form, .ticket { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.ticket-form { display: grid; gap: 0.6rem; align-self: start; }
input, select, textarea { width: 100%; border: 1px solid #c9d8e3; border-radius: 10px; padding: 0.55rem; font: inherit; }
button { border: 1px solid #0a4c74; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); color: #fff; padding: 0.5rem 1rem; border-radius: 999px; font-weight: 700; }
.tickets { display: grid; gap: 0.8rem; }
.ticket p { margin-top: 0.3rem; }
.ticket small { color: #6f8798; }
@media (max-width: 960px) { .ticket-layout { grid-template-columns: 1fr; } }
</style>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
# 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