diff --git a/Website/data/appointments.json b/Website/data/appointments.json index 4381388..e69de29 100644 --- a/Website/data/appointments.json +++ b/Website/data/appointments.json @@ -1,58 +0,0 @@ -[ - { - "id": "a-1774281965267", - "username": "Aiirondev", - "display_name": "Maximilian Gr\u00fcndinger", - "date": "2026-03-24", - "time": "10:00", - "subject": "Test", - "note": "", - "status": "Abgelehnt", - "created_at": "2026-03-23T16:06:05Z", - "response": "Es geht leider nicht um die von ihnen angefragte Uhrzeit, bitte versuchen sie es zwei Tage sp\u00e4ter", - "responded_at": "2026-03-23T16:06:52Z", - "responded_by": "Aiirondev" - }, - { - "id": "a-1774287654152", - "username": "Aiirondev", - "display_name": "Maximilian Gr\u00fcndinger", - "date": "2026-03-26", - "time": "10:00", - "subject": "Test", - "note": "Test", - "status": "Bestaetigt", - "created_at": "2026-03-23T17:40:54Z", - "response": "Wunderbar bis dann!", - "responded_at": "2026-03-23T17:41:10Z", - "responded_by": "Aiirondev" - }, - { - "id": "a-1774296722216", - "username": "Aiirondev", - "display_name": "Maximilian Gr\u00fcndinger", - "date": "2026-03-27", - "time": "14:30", - "subject": "Erst Gespr\u00e4ch f\u00fcr Grunschule ...", - "note": "Erste kontakt aufnahme", - "status": "Bestaetigt", - "created_at": "2026-03-23T20:12:02Z", - "response": "Ja passt so, wir kommen vorbei", - "responded_at": "2026-03-23T20:12:43Z", - "responded_by": "Aiirondev" - }, - { - "id": "a-1774335231538", - "username": "Aiirondev", - "display_name": "Maximilian Gr\u00fcndinger", - "date": "2026-03-26", - "time": "13:00", - "subject": "Erst Gespr\u00e4ch", - "note": "Test", - "status": "Bestaetigt", - "created_at": "2026-03-24T06:53:51Z", - "response": "Es w\u00fcrde an dem von ihnen andgefragten Datum eine Stunde gehen", - "responded_at": "2026-03-24T06:54:42Z", - "responded_by": "Aiirondev" - } -] \ No newline at end of file diff --git a/Website/main.py b/Website/main.py index 1857b0b..97db12a 100644 --- a/Website/main.py +++ b/Website/main.py @@ -1,10 +1,8 @@ from flask_jwt_extended import JWTManager, create_access_token, jwt_required from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session import os -import json import calendar from datetime import timedelta, datetime, date -from pathlib import Path from functools import wraps from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename @@ -36,14 +34,10 @@ def set_security_headers(response): response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data:; connect-src 'self';" return response -BASE_DIR = Path(__file__).resolve().parent -DATA_DIR = BASE_DIR / "data" -INVOICE_UPLOAD_DIR = BASE_DIR / "static" / "uploads" / "invoices" -USERS_FILE = DATA_DIR / "users.json" -APPOINTMENTS_FILE = DATA_DIR / "appointments.json" -POSTS_FILE = DATA_DIR / "posts.json" +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices") MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017") -MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Inventarsystem") +MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website") def _issue_access_token() -> str: @@ -64,10 +58,10 @@ def _save_invoice_pdf(file_obj, invoice_number: str) -> str | None: original_name = secure_filename(file_obj.filename) if not _is_allowed_invoice_filename(original_name): return None - INVOICE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + os.makedirs(INVOICE_UPLOAD_DIR, exist_ok=True) safe_invoice = secure_filename(invoice_number or "invoice") unique_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{safe_invoice}_{original_name}" - target = INVOICE_UPLOAD_DIR / unique_name + target = os.path.join(INVOICE_UPLOAD_DIR, unique_name) file_obj.save(target) return f"uploads/invoices/{unique_name}" @@ -108,78 +102,6 @@ def _list_users_for_admin() -> list: 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", - "pdf_path": "", - "created_at": _utc_now_iso(), - } - ) - except PyMongoError: - return - finally: - if client: - client.close() - - -def _ensure_data_files() -> None: - DATA_DIR.mkdir(parents=True, exist_ok=True) - if not USERS_FILE.exists(): - USERS_FILE.write_text("[]", encoding="utf-8") - if not APPOINTMENTS_FILE.exists(): - APPOINTMENTS_FILE.write_text("[]", encoding="utf-8") - if not POSTS_FILE.exists(): - POSTS_FILE.write_text("[]", encoding="utf-8") - - -def _read_json(file_path: Path) -> list: - _ensure_data_files() - try: - return json.loads(file_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return [] - - -def _write_json(file_path: Path, payload: list) -> None: - _ensure_data_files() - file_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8") - - def _sanitize_text(text: str, max_length: int = 255) -> str: """Sanitize user text input: strip, limit length, and escape HTML.""" text = (text or "").strip() @@ -188,6 +110,74 @@ def _sanitize_text(text: str, max_length: int = 255) -> str: return text +def _with_public_id(doc: dict | None) -> dict | None: + if not doc: + return doc + if not doc.get("id") and doc.get("_id") is not None: + doc["id"] = str(doc.get("_id")) + return doc + + +def _appointment_query_from_id(appointment_id: str) -> dict | None: + lookup = (appointment_id or "").strip() + if not lookup: + return None + if lookup.startswith("a-"): + return {"id": lookup} + try: + return {"_id": ObjectId(lookup)} + except Exception: + return {"id": lookup} + + +def _post_query_from_id(post_id: str) -> dict | None: + lookup = (post_id or "").strip() + if not lookup: + return None + if lookup.startswith("p-"): + return {"id": lookup} + try: + return {"_id": ObjectId(lookup)} + except Exception: + return {"id": lookup} + + +def _get_blocked_days() -> list: + client = None + entries = [] + try: + client, col = _get_collection("blocked_days") + entries = list(col.find({}, {"_id": 0}).sort("date", 1)) + except PyMongoError: + return [] + finally: + if client: + client.close() + + normalized = [] + for item in entries: + day = (item.get("date") or "").strip() + if not day: + continue + normalized.append( + { + "date": day, + "reason": (item.get("reason") or "").strip()[:200], + "blocked_by": (item.get("blocked_by") or "").strip()[:80], + "created_at": item.get("created_at") or "", + } + ) + normalized.sort(key=lambda value: value.get("date", "")) + return normalized + + +def _get_blocked_day_map() -> dict: + blocked = {} + for item in _get_blocked_days(): + blocked[item.get("date", "")] = item + return blocked + + def _sanitize_html(html_content: str, max_length: int = 50000) -> str: """Sanitize HTML content: allow safe tags only.""" if not html_content: @@ -321,8 +311,6 @@ def login(): session['display_name'] = stored_user.get("display_name") or stored_user.get("username") session['is_admin'] = stored_user.get("is_admin", False) session['access_token'] = _issue_access_token() - _ensure_user_license(session['username'], session['display_name']) - _ensure_user_invoice(session['username']) if request.is_json: return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200 @@ -400,6 +388,11 @@ def appointments(): appointment_time = (request.form.get("appointment_time") or "").strip() subject = (request.form.get("subject") or "").strip() note = (request.form.get("note") or "").strip() + meeting_type = (request.form.get("meeting_type") or "digital").strip().lower() + location_name = _sanitize_text(request.form.get("location_name") or "", 200) + location_maps_url = _sanitize_text(request.form.get("location_maps_url") or "", 1000) + + blocked_day_map = _get_blocked_day_map() try: date.fromisoformat(selected_date) @@ -407,28 +400,55 @@ def appointments(): flash("Bitte einen gueltigen Termin im Kalender auswaehlen.", "error") return redirect(url_for("appointments", month=month, year=year)) + if selected_date in blocked_day_map: + flash("Dieser Tag ist im Kalender gesperrt. Bitte einen anderen Termin waehlen.", "error") + return redirect(url_for("appointments", month=month, year=year)) + if not appointment_time or not subject: flash("Bitte Uhrzeit und Betreff ausfuellen.", "error") return redirect(url_for("appointments", month=month, year=year)) + if meeting_type not in ["digital", "vor_ort"]: + flash("Bitte eine gueltige Terminart waehlen.", "error") + return redirect(url_for("appointments", month=month, year=year)) + + if meeting_type == "vor_ort" and not location_name: + flash("Bei Vor-Ort-Terminen ist ein Ort erforderlich.", "error") + return redirect(url_for("appointments", month=month, year=year)) + + if meeting_type == "digital": + location_name = "" + location_maps_url = "" + subject = _sanitize_text(subject, 200) note = _sanitize_text(note, 2000) - entries = _read_json(APPOINTMENTS_FILE) - entries.append( - { - "id": f"a-{int(datetime.utcnow().timestamp() * 1000)}", - "username": session.get("username"), - "display_name": session.get("display_name"), - "date": selected_date, - "time": appointment_time, - "subject": subject, - "note": note, - "status": "Angefragt", - "created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", - } - ) - _write_json(APPOINTMENTS_FILE, entries) + client = None + try: + client, col = _get_collection("appointments") + col.insert_one( + { + "id": f"a-{int(datetime.utcnow().timestamp() * 1000)}", + "username": session.get("username"), + "display_name": session.get("display_name"), + "date": selected_date, + "time": appointment_time, + "subject": subject, + "meeting_type": meeting_type, + "meeting_label": "Digital" if meeting_type == "digital" else "Vor Ort", + "location_name": location_name, + "location_maps_url": location_maps_url, + "note": note, + "status": "Angefragt", + "created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", + } + ) + except PyMongoError: + flash("Termin konnte nicht gespeichert werden.", "error") + return redirect(url_for("appointments", month=month, year=year)) + finally: + if client: + client.close() flash("Termin erfolgreich angefragt.", "success") selected = date.fromisoformat(selected_date) @@ -450,9 +470,20 @@ def appointments(): next_month = 1 next_year += 1 - all_appointments = _read_json(APPOINTMENTS_FILE) - user_appointments = [item for item in all_appointments if item.get("username") == session.get("username")] - user_appointments.sort(key=lambda item: (item.get("date", ""), item.get("time", "")), reverse=False) + user_appointments = [] + client = None + try: + client, col = _get_collection("appointments") + user_appointments = list(col.find({"username": session.get("username")}).sort([("date", 1), ("time", 1)])) + for item in user_appointments: + _with_public_id(item) + except PyMongoError: + flash("Termine konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + + blocked_day_map = _get_blocked_day_map() return render_template( "appointments.html", @@ -465,6 +496,7 @@ def appointments(): previous_year=previous_year, next_month=next_month, next_year=next_year, + blocked_day_map=blocked_day_map, user_appointments=user_appointments, ) @@ -472,8 +504,20 @@ def appointments(): @app.route('/admin/dashboard') @admin_required def admin_dashboard(): - all_appointments = _read_json(APPOINTMENTS_FILE) - all_appointments.sort(key=lambda x: (x.get("date", ""), x.get("time", "")), reverse=True) + all_appointments = [] + client = None + try: + client, col = _get_collection("appointments") + all_appointments = list(col.find().sort([("date", -1), ("time", -1)])) + for item in all_appointments: + _with_public_id(item) + except PyMongoError: + flash("Terminanfragen konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + + blocked_days = _get_blocked_days() status_counts = { "Angefragt": len([a for a in all_appointments if a.get("status") == "Angefragt"]), @@ -481,17 +525,75 @@ def admin_dashboard(): "Abgelehnt": len([a for a in all_appointments if a.get("status") == "Abgelehnt"]), } - posts = _read_json(POSTS_FILE) - total_posts = len(posts) + total_posts = 0 + client = None + try: + client, col = _get_collection("posts") + total_posts = col.count_documents({}) + except PyMongoError: + total_posts = 0 + finally: + if client: + client.close() return render_template( "admin_dashboard.html", appointments=all_appointments, + blocked_days=blocked_days, status_counts=status_counts, total_posts=total_posts, ) +@app.route('/admin/appointments/block-day', methods=['POST']) +@admin_required +def admin_block_day(): + action = _sanitize_text(request.form.get("action") or "", 30) + block_date = (request.form.get("block_date") or "").strip() + reason = _sanitize_text(request.form.get("reason") or "", 200) + + client = None + try: + client, col = _get_collection("blocked_days") + + if action == "add": + try: + date.fromisoformat(block_date) + except ValueError: + flash("Bitte ein gueltiges Datum zum Sperren waehlen.", "error") + return redirect(url_for("admin_dashboard")) + + if col.find_one({"date": block_date}): + flash("Der Tag ist bereits gesperrt.", "error") + return redirect(url_for("admin_dashboard")) + + col.insert_one( + { + "date": block_date, + "reason": reason, + "blocked_by": session.get("username") or "admin", + "created_at": _utc_now_iso(), + } + ) + flash("Tag im Kalender gesperrt.", "success") + elif action == "remove": + result = col.delete_one({"date": block_date}) + if not result.deleted_count: + flash("Sperrtag nicht gefunden.", "error") + return redirect(url_for("admin_dashboard")) + flash("Sperrtag entfernt.", "success") + else: + flash("Ungueltige Aktion fuer Kalendersperre.", "error") + return redirect(url_for("admin_dashboard")) + except PyMongoError: + flash("Kalendersperre konnte nicht gespeichert werden.", "error") + finally: + if client: + client.close() + + return redirect(url_for("admin_dashboard")) + + @app.route('/admin/appointment/', methods=['POST']) @admin_required def update_appointment(appointment_id): @@ -502,27 +604,37 @@ def update_appointment(appointment_id): flash("Ungueltige Aktion.", "error") return redirect(url_for("admin_dashboard")) - all_appointments = _read_json(APPOINTMENTS_FILE) - appointment = None - for item in all_appointments: - if item.get("id") == appointment_id: - appointment = item - break - - if not appointment: + query = _appointment_query_from_id(appointment_id) + if not query: flash("Termin nicht gefunden.", "error") return redirect(url_for("admin_dashboard")) - - if action == "confirm": - appointment["status"] = "Bestaetigt" - else: - appointment["status"] = "Abgelehnt" - - appointment["response"] = response_text - appointment["responded_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z" - appointment["responded_by"] = session.get("username") - - _write_json(APPOINTMENTS_FILE, all_appointments) + + new_status = "Bestaetigt" if action == "confirm" else "Abgelehnt" + + client = None + try: + client, col = _get_collection("appointments") + result = col.update_one( + query, + { + "$set": { + "status": new_status, + "response": response_text, + "responded_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", + "responded_by": session.get("username"), + } + }, + ) + if result.matched_count == 0: + flash("Termin nicht gefunden.", "error") + return redirect(url_for("admin_dashboard")) + except PyMongoError: + flash("Termin konnte nicht aktualisiert werden.", "error") + return redirect(url_for("admin_dashboard")) + finally: + if client: + client.close() + flash(f"Termin wurde {('bestaetigt' if action == 'confirm' else 'abgelehnt')}.", "success") return redirect(url_for("admin_dashboard")) @@ -541,61 +653,106 @@ def admin_blog(): if not title or not content: flash("Bitte Titel und inhalt ausfuellen.", "error") return redirect(url_for("admin_blog")) - - posts = _read_json(POSTS_FILE) - posts.append({ - "id": f"p-{int(datetime.utcnow().timestamp() * 1000)}", - "title": title, - "excerpt": excerpt or (content[:150] + "...") if len(content) > 150 else content, - "content": content, - "author": session.get("username"), - "created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", - "published": True, - }) - _write_json(POSTS_FILE, posts) + client = None + try: + client, col = _get_collection("posts") + col.insert_one( + { + "id": f"p-{int(datetime.utcnow().timestamp() * 1000)}", + "title": title, + "excerpt": excerpt or (content[:150] + "...") if len(content) > 150 else content, + "content": content, + "author": session.get("username"), + "created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", + "published": True, + } + ) + except PyMongoError: + flash("Beitrag konnte nicht gespeichert werden.", "error") + return redirect(url_for("admin_blog")) + finally: + if client: + client.close() + flash("Beitrag veroeffentlicht.", "success") return redirect(url_for("admin_blog")) elif action == "delete": post_id = (request.form.get("post_id") or "").strip() - if not post_id or not post_id.startswith("p-"): + query = _post_query_from_id(post_id) + if not query: flash("Ungueltige Beitrag-ID.", "error") return redirect(url_for("admin_blog")) - posts = _read_json(POSTS_FILE) - original_count = len(posts) - posts = [p for p in posts if p.get("id") != post_id] - if len(posts) == original_count: + client = None + try: + client, col = _get_collection("posts") + result = col.delete_one(query) + except PyMongoError: + flash("Beitrag konnte nicht geloescht werden.", "error") + return redirect(url_for("admin_blog")) + finally: + if client: + client.close() + + if not result.deleted_count: flash("Beitrag nicht gefunden.", "error") return redirect(url_for("admin_blog")) - _write_json(POSTS_FILE, posts) + flash("Beitrag geloescht.", "success") return redirect(url_for("admin_blog")) - - posts = _read_json(POSTS_FILE) - posts.sort(key=lambda x: x.get("created_at", ""), reverse=True) + + posts = [] + client = None + try: + client, col = _get_collection("posts") + posts = list(col.find().sort("created_at", -1)) + for item in posts: + _with_public_id(item) + except PyMongoError: + flash("Blogbeitraege konnten nicht geladen werden.", "error") + finally: + if client: + client.close() + return render_template("admin_blog.html", posts=posts) @app.route('/blog') def blog(): - posts = _read_json(POSTS_FILE) - posts.sort(key=lambda x: x.get("created_at", ""), reverse=True) + posts = [] + client = None + try: + client, col = _get_collection("posts") + posts = list(col.find({"published": True}).sort("created_at", -1)) + for item in posts: + _with_public_id(item) + except PyMongoError: + posts = [] + finally: + if client: + client.close() + return render_template("blog.html", posts=posts) @app.route('/blog/') def blog_post(post_id): - if not post_id or not post_id.startswith("p-"): + query = _post_query_from_id(post_id) + if not query: flash("Ungueltige Beitrag-ID.", "error") return redirect(url_for("blog")) - - posts = _read_json(POSTS_FILE) + client = None post = None - for p in posts: - if p.get("id") == post_id: - post = p - break - + try: + client, col = _get_collection("posts") + post = col.find_one(query) + _with_public_id(post) + except PyMongoError: + post = None + finally: + if client: + client.close() + if not post: flash("Beitrag nicht gefunden.", "error") return redirect(url_for("blog")) @@ -607,40 +764,7 @@ def blog_post(post_id): @login_required def my_licenses(): if request.method == 'POST': - action = _sanitize_text(request.form.get("action") or "", 30) - license_id = _sanitize_text(request.form.get("license_id") or "", 64) - target_username = _sanitize_text(request.form.get("target_username") or "", 80) - - if action != "transfer" or not license_id or not target_username: - flash("Ungueltige Weitergabe-Angaben.", "error") - return redirect(url_for("my_licenses")) - - if target_username == session.get("username"): - flash("Bitte einen anderen Nutzer waehlen.", "error") - return redirect(url_for("my_licenses")) - - client = None - try: - client, col = _get_collection("licenses") - result = col.update_one( - {"_id": ObjectId(license_id), "username": session.get("username")}, - { - "$set": { - "username": target_username, - "transferred_at": _utc_now_iso(), - "transferred_by": session.get("username"), - } - }, - ) - if result.modified_count: - flash("Lizenz wurde erfolgreich weitergegeben.", "success") - else: - flash("Lizenz konnte nicht weitergegeben werden.", "error") - except Exception: - flash("Weitergabe fehlgeschlagen.", "error") - finally: - if client: - client.close() + flash("Weitergabe von Lizenzen ist deaktiviert.", "error") return redirect(url_for("my_licenses")) licenses = [] @@ -656,8 +780,7 @@ def my_licenses(): if client: client.close() - transfer_users = [u for u in _list_users_for_admin() if u.get("username") != session.get("username")] - return render_template("my_licenses.html", licenses=licenses, transfer_users=transfer_users) + return render_template("my_licenses.html", licenses=licenses) @app.route('/my/invoices') @@ -945,24 +1068,6 @@ def admin_licenses(): ) flash("Lizenz aktualisiert.", "success") - elif action == "transfer" and license_id: - target_username = _sanitize_text(request.form.get("target_username") or "", 80) - if not target_username: - flash("Bitte Zielnutzer waehlen.", "error") - return redirect(url_for("admin_licenses")) - - col.update_one( - {"_id": ObjectId(license_id)}, - { - "$set": { - "username": target_username, - "transferred_at": _utc_now_iso(), - "transferred_by": session.get("username"), - } - }, - ) - flash("Lizenz weitergegeben.", "success") - elif action == "delete" and license_id: col.delete_one({"_id": ObjectId(license_id)}) flash("Lizenz geloescht.", "success") diff --git a/Website/templates/admin_dashboard.html b/Website/templates/admin_dashboard.html index ae5d5c7..86f2836 100644 --- a/Website/templates/admin_dashboard.html +++ b/Website/templates/admin_dashboard.html @@ -48,6 +48,76 @@ margin-bottom: 0.8rem; } + .block-days-section { + margin-bottom: 1.2rem; + padding: 1rem; + border: 1px solid #d8e3ec; + border-radius: 12px; + background: #f9fbfd; + } + + .block-days-section h3 { + margin: 0 0 0.6rem; + color: #143a55; + font-size: 1.05rem; + } + + .block-form { + display: grid; + grid-template-columns: 180px 1fr auto; + gap: 0.5rem; + margin-bottom: 0.8rem; + } + + .block-form input { + width: 100%; + border: 1px solid #c4d4e0; + border-radius: 8px; + padding: 0.45rem 0.55rem; + font: inherit; + } + + .block-btn, + .unblock-btn { + border: none; + border-radius: 8px; + padding: 0.45rem 0.65rem; + font-weight: 700; + cursor: pointer; + } + + .block-btn { + color: #ffffff; + background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%); + } + + .blocked-list { + display: grid; + gap: 0.45rem; + } + + .blocked-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + border: 1px solid #d5dee6; + border-radius: 9px; + padding: 0.5rem 0.65rem; + background: #ffffff; + } + + .blocked-item p { + margin: 0; + font-size: 0.88rem; + } + + .unblock-btn { + color: #7e2d2d; + background: #fff3f3; + border: 1px solid #e8b8b8; + } + .appointment-card { border: 1px solid #d8e3ec; border-radius: 12px; @@ -141,6 +211,10 @@ .admin-grid { grid-template-columns: 1fr 1fr; } + + .block-form { + grid-template-columns: 1fr; + } } @media (max-width: 600px) { @@ -175,12 +249,49 @@

Terminanfragen verwalten

+
+

Kalender-Tage sperren

+
+ + + + +
+
+ {% if blocked_days %} + {% for blocked in blocked_days %} +
+

+ {{ blocked.date }} + {% if blocked.reason %} + - {{ blocked.reason }} + {% endif %} +

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

Aktuell sind keine Tage gesperrt.

+ {% endif %} +
+
{% if appointments %} {% for appointment in appointments %}
{{ appointment.display_name }} - {{ appointment.subject }}

Datum: {{ appointment.date }} um {{ appointment.time }}

+

Terminart: {{ appointment.meeting_label or ('Vor Ort' if appointment.meeting_type == 'vor_ort' else 'Digital') }}

+ {% if appointment.location_name %} +

Ort: {{ appointment.location_name }}

+ {% endif %} + {% if appointment.location_maps_url %} +

Maps: Link öffnen

+ {% endif %}

Angefragt: {{ appointment.created_at[:10] }}

{% if appointment.note %}

Notiz: {{ appointment.note }}

diff --git a/Website/templates/admin_licenses.html b/Website/templates/admin_licenses.html index c25a357..b5c4233 100644 --- a/Website/templates/admin_licenses.html +++ b/Website/templates/admin_licenses.html @@ -45,19 +45,6 @@ -
- - - - -
diff --git a/Website/templates/appointments.html b/Website/templates/appointments.html index 54a7dc6..edf6253 100644 --- a/Website/templates/appointments.html +++ b/Website/templates/appointments.html @@ -94,6 +94,18 @@ color: #ffffff; } + .day.blocked { + background: #f3f4f7; + color: #8b96a1; + border-color: #d2d8de; + cursor: not-allowed; + } + + .day.blocked:hover { + transform: none; + border-color: #d2d8de; + } + .booking-form h2, .appointment-list h2 { font-size: 1.2rem; @@ -124,7 +136,8 @@ } .field input, - .field textarea { + .field textarea, + .field select { width: 100%; border: 1px solid #c4d4e0; border-radius: 10px; @@ -180,6 +193,41 @@ background: #eef4f8; } + .meeting-note { + margin: 0 0 0.75rem; + color: #27526d; + font-weight: 600; + } + + .helper-line { + margin-top: 0.35rem; + font-size: 0.84rem; + color: #4f6f83; + } + + .maps-tools { + display: flex; + gap: 0.45rem; + margin-top: 0.45rem; + } + + .maps-btn { + border: 1px solid #8fb1c8; + background: #f6fbff; + color: #1d5575; + border-radius: 999px; + padding: 0.35rem 0.7rem; + font-size: 0.84rem; + font-weight: 700; + cursor: pointer; + } + + .entry-meta { + margin-top: 0.2rem; + color: #345e78; + font-size: 0.9rem; + } + @media (max-width: 960px) { .appointments-grid { grid-template-columns: 1fr; @@ -216,10 +264,15 @@ {% else %} {% set day_iso = "%04d-%02d-%02d"|format(year, month, day) %} + {% set blocked_day = blocked_day_map.get(day_iso) %} @@ -231,6 +284,7 @@
+
+ + +
+
@@ -260,6 +330,13 @@ {% if item.note %}

{{ item.note }}

{% endif %} + + {% if item.location_name %} + + {% endif %} + {% if item.location_maps_url %} + + {% endif %} {% if item.response %}

Admin-Antwort: {{ item.response }}

{% endif %} @@ -276,6 +353,10 @@ const dayGrid = document.getElementById("dayGrid"); const selectedDateInput = document.getElementById("selectedDateInput"); const selectedDateBadge = document.getElementById("selectedDateBadge"); + const meetingType = document.getElementById("meeting_type"); + const locationField = document.getElementById("locationField"); + const locationNameInput = document.getElementById("location_name"); + const mapsSearchBtn = document.getElementById("mapsSearchBtn"); function setSelection(dateValue, button) { selectedDateInput.value = dateValue; @@ -290,6 +371,16 @@ } } + function updateLocationRequirement() { + const isOnsite = meetingType.value === "vor_ort"; + locationField.style.display = isOnsite ? "block" : "none"; + locationNameInput.required = isOnsite; + if (!isOnsite) { + locationNameInput.value = ""; + document.getElementById("location_maps_url").value = ""; + } + } + dayGrid.addEventListener("click", function (event) { const target = event.target.closest(".day[data-date]"); if (!target) { @@ -297,6 +388,16 @@ } setSelection(target.dataset.date, target); }); + + meetingType.addEventListener("change", updateLocationRequirement); + + mapsSearchBtn.addEventListener("click", function () { + const query = (locationNameInput.value || "").trim(); + const base = "https://www.google.com/maps/search/?api=1&query="; + window.open(base + encodeURIComponent(query || "meeting location"), "_blank", "noopener"); + }); + + updateLocationRequirement(); })(); {% endblock %} diff --git a/Website/templates/base.html b/Website/templates/base.html index 3846fb7..1d23244 100644 --- a/Website/templates/base.html +++ b/Website/templates/base.html @@ -42,6 +42,12 @@ var(--bg-main); } + body { + min-height: 100vh; + display: flex; + flex-direction: column; + } + h1 { margin: 0; font-family: "Space Grotesk", system-ui, sans-serif; @@ -210,6 +216,7 @@ } .main-wrap { + flex: 1 0 auto; padding: 2rem 0 3.4rem; } @@ -354,22 +361,66 @@ .site-footer { background: linear-gradient(135deg, var(--brand-strong) 0%, #0a4c74 100%); color: #ffffff; - margin-top: 4rem; + margin-top: auto; padding: 3rem 0 1.5rem; border-top: 1px solid var(--line); + position: relative; + overflow: hidden; + animation: footerEnter 0.7s ease both; + will-change: transform, opacity; + transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.45s ease, box-shadow 0.45s ease; + } + + body.footer-scroll-mode .site-footer { + opacity: 0.9; + transform: translateY(26px); + box-shadow: 0 -8px 22px rgba(5, 27, 41, 0.14); + } + + body.footer-scroll-mode.footer-reveal .site-footer { + opacity: 1; + transform: translateY(0); + box-shadow: 0 -14px 34px rgba(5, 27, 41, 0.2); + } + + .site-footer::before { + content: ""; + position: absolute; + inset: -40% -10% auto -10%; + height: 180px; + background: radial-gradient(circle at 20% 50%, rgba(242, 140, 40, 0.25) 0%, rgba(242, 140, 40, 0) 62%); + transform: translateX(-12%); + animation: footerGlow 7s ease-in-out infinite; + pointer-events: none; } .footer-inner { + position: relative; + z-index: 1; + display: grid; + gap: 2rem; + margin-bottom: 2rem; + } + + .footer-content { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem; - margin-bottom: 2rem; } .footer-section { display: flex; flex-direction: column; gap: 0.75rem; + animation: footerSectionRise 0.65s ease both; + } + + .footer-section:nth-child(2) { + animation-delay: 0.08s; + } + + .footer-section:nth-child(3) { + animation-delay: 0.16s; } .footer-section h4 { @@ -395,11 +446,12 @@ .footer-links a { color: rgba(255, 255, 255, 0.8); font-size: 0.95rem; - transition: color 0.2s ease; + transition: color 0.2s ease, transform 0.2s ease; } .footer-links a:hover { color: #ffffff; + transform: translateX(4px); } .footer-bottom { @@ -414,8 +466,63 @@ margin: 0; } + @keyframes footerEnter { + from { + opacity: 0; + transform: translateY(22px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes footerSectionRise { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes footerGlow { + 0%, + 100% { + transform: translateX(-12%); + opacity: 0.65; + } + 50% { + transform: translateX(12%); + opacity: 0.95; + } + } + + @media (prefers-reduced-motion: reduce) { + .site-footer, + .site-footer::before, + .footer-section, + .footer-links a { + animation: none; + transition: none; + } + + body.footer-scroll-mode .site-footer, + body.footer-scroll-mode.footer-reveal .site-footer { + opacity: 1; + transform: none; + box-shadow: none; + } + } + @media (max-width: 700px) { .footer-inner { + gap: 1.5rem; + } + + .footer-content { grid-template-columns: 1fr; gap: 1.5rem; } @@ -425,5 +532,57 @@ + \ No newline at end of file diff --git a/Website/templates/my_licenses.html b/Website/templates/my_licenses.html index 1700215..a7701d5 100644 --- a/Website/templates/my_licenses.html +++ b/Website/templates/my_licenses.html @@ -17,17 +17,6 @@

Lizenzschlüssel: {{ item.license_key }}

Status: {{ item.status }}

Gültig bis: {{ item.valid_until }}

- - - - - -
{% endfor %} {% else %} @@ -43,8 +32,5 @@ .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; } -.transfer-form { margin-top: 0.8rem; display: grid; gap: 0.45rem; } -select, button { border: 1px solid #c9d8e3; border-radius: 10px; padding: 0.52rem; font: inherit; } -button { border-radius: 999px; color: #fff; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); border-color: #0a4c74; font-weight: 700; } {% endblock %} diff --git a/Website/user.py b/Website/user.py index 62228e3..ce6c21c 100644 --- a/Website/user.py +++ b/Website/user.py @@ -43,7 +43,7 @@ def check_nm_pwd(username, password): dict: User document if credentials are valid, None otherwise """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] hashed_password = hashlib.sha512(password.encode()).hexdigest() user = users.find_one({'Username': username, 'Password': hashed_password}) @@ -63,7 +63,7 @@ def add_user(username, password, name, last_name): bool: True if user was added successfully, False if password was too weak """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] if not check_password_strength(password): return False @@ -83,7 +83,7 @@ def make_admin(username): bool: True if user was promoted successfully """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] users.update_one({'Username': username}, {'$set': {'Admin': True}}) client.close() @@ -100,7 +100,7 @@ def remove_admin(username): bool: True if user was demoted successfully """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] users.update_one({'Username': username}, {'$set': {'Admin': False}}) client.close() @@ -117,7 +117,7 @@ def get_user(username): dict: User document or None if not found """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] users_return = users.find_one({'Username': username}) client.close() @@ -135,7 +135,7 @@ def check_admin(username): bool: True if user is an administrator, False otherwise """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] user = users.find_one({'Username': username}) client.close() @@ -153,7 +153,7 @@ def delete_user(username): bool: True if user was deleted successfully, False otherwise """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] result = users.delete_one({'username': username}) client.close() @@ -175,7 +175,7 @@ def get_name(username): str: String of name """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] user = users.find_one({'Username': username}) name = user.get("name") @@ -189,7 +189,7 @@ def get_last_name(username): str: String of last_name """ client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] user = users.find_one({'Username': username}) name = user.get("last_name") @@ -206,7 +206,7 @@ def get_all_users(): """ try: client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] all_users = list(users.find()) client.close() @@ -230,7 +230,7 @@ def update_password(username, new_password): return False client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] # Hash the new password @@ -262,7 +262,7 @@ def update_user_name(username, name, last_name): """ try: client = MongoClient('localhost', 27017) - db = client['Inventarsystem'] + db = client['Invario_Website'] users = db['users'] result = users.update_one(