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:
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
.venv
|
.venv
|
||||||
__pycache__
|
__pycache__
|
||||||
Inventarsystem_Lizenz_Verwaltung
|
Inventarsystem_Lizenz_Verwaltung
|
||||||
main.dist
|
main.dist
|
||||||
|
.mongo-data
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"python-envs.pythonProjects": [
|
||||||
|
{
|
||||||
|
"path": "Website/.venv",
|
||||||
|
"envManager": "ms-python.python:venv",
|
||||||
|
"packageManager": "ms-python.python:pip"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+549
-20
@@ -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,14 +188,28 @@ 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)
|
||||||
return None
|
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):
|
def login_required(view_func):
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user