Enhance admin dashboard with block day functionality, improve appointment details, and update footer animations
- Added a section in the admin dashboard for blocking calendar days with a form to submit block dates and reasons. - Implemented a list of blocked days with the ability to unblock them. - Enhanced appointment details to include meeting type and location information. - Updated styles for blocked days in the appointment calendar. - Improved footer design with animations and responsive adjustments. - Removed unused license transfer forms from admin and user license templates. - Changed database name references from 'Inventarsystem' to 'Invario_Website' in user management functions.
This commit is contained in:
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
+310
-205
@@ -1,10 +1,8 @@
|
|||||||
from flask_jwt_extended import JWTManager, create_access_token, jwt_required
|
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
|
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import calendar
|
import calendar
|
||||||
from datetime import timedelta, datetime, date
|
from datetime import timedelta, datetime, date
|
||||||
from pathlib import Path
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from werkzeug.utils import secure_filename
|
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';"
|
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
|
return response
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
DATA_DIR = BASE_DIR / "data"
|
INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices")
|
||||||
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"
|
|
||||||
MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
|
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:
|
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)
|
original_name = secure_filename(file_obj.filename)
|
||||||
if not _is_allowed_invoice_filename(original_name):
|
if not _is_allowed_invoice_filename(original_name):
|
||||||
return None
|
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")
|
safe_invoice = secure_filename(invoice_number or "invoice")
|
||||||
unique_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{safe_invoice}_{original_name}"
|
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)
|
file_obj.save(target)
|
||||||
return f"uploads/invoices/{unique_name}"
|
return f"uploads/invoices/{unique_name}"
|
||||||
|
|
||||||
@@ -108,78 +102,6 @@ def _list_users_for_admin() -> list:
|
|||||||
return users
|
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:
|
def _sanitize_text(text: str, max_length: int = 255) -> str:
|
||||||
"""Sanitize user text input: strip, limit length, and escape HTML."""
|
"""Sanitize user text input: strip, limit length, and escape HTML."""
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
@@ -188,6 +110,74 @@ def _sanitize_text(text: str, max_length: int = 255) -> str:
|
|||||||
return text
|
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:
|
def _sanitize_html(html_content: str, max_length: int = 50000) -> str:
|
||||||
"""Sanitize HTML content: allow safe tags only."""
|
"""Sanitize HTML content: allow safe tags only."""
|
||||||
if not html_content:
|
if not html_content:
|
||||||
@@ -321,8 +311,6 @@ def login():
|
|||||||
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
|
||||||
@@ -400,6 +388,11 @@ def appointments():
|
|||||||
appointment_time = (request.form.get("appointment_time") or "").strip()
|
appointment_time = (request.form.get("appointment_time") or "").strip()
|
||||||
subject = (request.form.get("subject") or "").strip()
|
subject = (request.form.get("subject") or "").strip()
|
||||||
note = (request.form.get("note") 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:
|
try:
|
||||||
date.fromisoformat(selected_date)
|
date.fromisoformat(selected_date)
|
||||||
@@ -407,28 +400,55 @@ def appointments():
|
|||||||
flash("Bitte einen gueltigen Termin im Kalender auswaehlen.", "error")
|
flash("Bitte einen gueltigen Termin im Kalender auswaehlen.", "error")
|
||||||
return redirect(url_for("appointments", month=month, year=year))
|
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:
|
if not appointment_time or not subject:
|
||||||
flash("Bitte Uhrzeit und Betreff ausfuellen.", "error")
|
flash("Bitte Uhrzeit und Betreff ausfuellen.", "error")
|
||||||
return redirect(url_for("appointments", month=month, year=year))
|
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)
|
subject = _sanitize_text(subject, 200)
|
||||||
note = _sanitize_text(note, 2000)
|
note = _sanitize_text(note, 2000)
|
||||||
|
|
||||||
entries = _read_json(APPOINTMENTS_FILE)
|
client = None
|
||||||
entries.append(
|
try:
|
||||||
{
|
client, col = _get_collection("appointments")
|
||||||
"id": f"a-{int(datetime.utcnow().timestamp() * 1000)}",
|
col.insert_one(
|
||||||
"username": session.get("username"),
|
{
|
||||||
"display_name": session.get("display_name"),
|
"id": f"a-{int(datetime.utcnow().timestamp() * 1000)}",
|
||||||
"date": selected_date,
|
"username": session.get("username"),
|
||||||
"time": appointment_time,
|
"display_name": session.get("display_name"),
|
||||||
"subject": subject,
|
"date": selected_date,
|
||||||
"note": note,
|
"time": appointment_time,
|
||||||
"status": "Angefragt",
|
"subject": subject,
|
||||||
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
"meeting_type": meeting_type,
|
||||||
}
|
"meeting_label": "Digital" if meeting_type == "digital" else "Vor Ort",
|
||||||
)
|
"location_name": location_name,
|
||||||
_write_json(APPOINTMENTS_FILE, entries)
|
"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")
|
flash("Termin erfolgreich angefragt.", "success")
|
||||||
selected = date.fromisoformat(selected_date)
|
selected = date.fromisoformat(selected_date)
|
||||||
@@ -450,9 +470,20 @@ def appointments():
|
|||||||
next_month = 1
|
next_month = 1
|
||||||
next_year += 1
|
next_year += 1
|
||||||
|
|
||||||
all_appointments = _read_json(APPOINTMENTS_FILE)
|
user_appointments = []
|
||||||
user_appointments = [item for item in all_appointments if item.get("username") == session.get("username")]
|
client = None
|
||||||
user_appointments.sort(key=lambda item: (item.get("date", ""), item.get("time", "")), reverse=False)
|
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(
|
return render_template(
|
||||||
"appointments.html",
|
"appointments.html",
|
||||||
@@ -465,6 +496,7 @@ def appointments():
|
|||||||
previous_year=previous_year,
|
previous_year=previous_year,
|
||||||
next_month=next_month,
|
next_month=next_month,
|
||||||
next_year=next_year,
|
next_year=next_year,
|
||||||
|
blocked_day_map=blocked_day_map,
|
||||||
user_appointments=user_appointments,
|
user_appointments=user_appointments,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -472,8 +504,20 @@ def appointments():
|
|||||||
@app.route('/admin/dashboard')
|
@app.route('/admin/dashboard')
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_dashboard():
|
def admin_dashboard():
|
||||||
all_appointments = _read_json(APPOINTMENTS_FILE)
|
all_appointments = []
|
||||||
all_appointments.sort(key=lambda x: (x.get("date", ""), x.get("time", "")), reverse=True)
|
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 = {
|
status_counts = {
|
||||||
"Angefragt": len([a for a in all_appointments if a.get("status") == "Angefragt"]),
|
"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"]),
|
"Abgelehnt": len([a for a in all_appointments if a.get("status") == "Abgelehnt"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
posts = _read_json(POSTS_FILE)
|
total_posts = 0
|
||||||
total_posts = len(posts)
|
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(
|
return render_template(
|
||||||
"admin_dashboard.html",
|
"admin_dashboard.html",
|
||||||
appointments=all_appointments,
|
appointments=all_appointments,
|
||||||
|
blocked_days=blocked_days,
|
||||||
status_counts=status_counts,
|
status_counts=status_counts,
|
||||||
total_posts=total_posts,
|
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/<appointment_id>', methods=['POST'])
|
@app.route('/admin/appointment/<appointment_id>', methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_appointment(appointment_id):
|
def update_appointment(appointment_id):
|
||||||
@@ -502,27 +604,37 @@ def update_appointment(appointment_id):
|
|||||||
flash("Ungueltige Aktion.", "error")
|
flash("Ungueltige Aktion.", "error")
|
||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
all_appointments = _read_json(APPOINTMENTS_FILE)
|
query = _appointment_query_from_id(appointment_id)
|
||||||
appointment = None
|
if not query:
|
||||||
for item in all_appointments:
|
|
||||||
if item.get("id") == appointment_id:
|
|
||||||
appointment = item
|
|
||||||
break
|
|
||||||
|
|
||||||
if not appointment:
|
|
||||||
flash("Termin nicht gefunden.", "error")
|
flash("Termin nicht gefunden.", "error")
|
||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
if action == "confirm":
|
new_status = "Bestaetigt" if action == "confirm" else "Abgelehnt"
|
||||||
appointment["status"] = "Bestaetigt"
|
|
||||||
else:
|
|
||||||
appointment["status"] = "Abgelehnt"
|
|
||||||
|
|
||||||
appointment["response"] = response_text
|
client = None
|
||||||
appointment["responded_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
try:
|
||||||
appointment["responded_by"] = session.get("username")
|
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()
|
||||||
|
|
||||||
_write_json(APPOINTMENTS_FILE, all_appointments)
|
|
||||||
flash(f"Termin wurde {('bestaetigt' if action == 'confirm' else 'abgelehnt')}.", "success")
|
flash(f"Termin wurde {('bestaetigt' if action == 'confirm' else 'abgelehnt')}.", "success")
|
||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
@@ -541,60 +653,105 @@ def admin_blog():
|
|||||||
if not title or not content:
|
if not title or not content:
|
||||||
flash("Bitte Titel und inhalt ausfuellen.", "error")
|
flash("Bitte Titel und inhalt ausfuellen.", "error")
|
||||||
return redirect(url_for("admin_blog"))
|
return redirect(url_for("admin_blog"))
|
||||||
|
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()
|
||||||
|
|
||||||
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)
|
|
||||||
flash("Beitrag veroeffentlicht.", "success")
|
flash("Beitrag veroeffentlicht.", "success")
|
||||||
return redirect(url_for("admin_blog"))
|
return redirect(url_for("admin_blog"))
|
||||||
|
|
||||||
elif action == "delete":
|
elif action == "delete":
|
||||||
post_id = (request.form.get("post_id") or "").strip()
|
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")
|
flash("Ungueltige Beitrag-ID.", "error")
|
||||||
return redirect(url_for("admin_blog"))
|
return redirect(url_for("admin_blog"))
|
||||||
posts = _read_json(POSTS_FILE)
|
client = None
|
||||||
original_count = len(posts)
|
try:
|
||||||
posts = [p for p in posts if p.get("id") != post_id]
|
client, col = _get_collection("posts")
|
||||||
if len(posts) == original_count:
|
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")
|
flash("Beitrag nicht gefunden.", "error")
|
||||||
return redirect(url_for("admin_blog"))
|
return redirect(url_for("admin_blog"))
|
||||||
_write_json(POSTS_FILE, posts)
|
|
||||||
flash("Beitrag geloescht.", "success")
|
flash("Beitrag geloescht.", "success")
|
||||||
return redirect(url_for("admin_blog"))
|
return redirect(url_for("admin_blog"))
|
||||||
|
|
||||||
posts = _read_json(POSTS_FILE)
|
posts = []
|
||||||
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
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)
|
return render_template("admin_blog.html", posts=posts)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/blog')
|
@app.route('/blog')
|
||||||
def blog():
|
def blog():
|
||||||
posts = _read_json(POSTS_FILE)
|
posts = []
|
||||||
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
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)
|
return render_template("blog.html", posts=posts)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/blog/<post_id>')
|
@app.route('/blog/<post_id>')
|
||||||
def blog_post(post_id):
|
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")
|
flash("Ungueltige Beitrag-ID.", "error")
|
||||||
return redirect(url_for("blog"))
|
return redirect(url_for("blog"))
|
||||||
|
client = None
|
||||||
posts = _read_json(POSTS_FILE)
|
|
||||||
post = None
|
post = None
|
||||||
for p in posts:
|
try:
|
||||||
if p.get("id") == post_id:
|
client, col = _get_collection("posts")
|
||||||
post = p
|
post = col.find_one(query)
|
||||||
break
|
_with_public_id(post)
|
||||||
|
except PyMongoError:
|
||||||
|
post = None
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
if not post:
|
if not post:
|
||||||
flash("Beitrag nicht gefunden.", "error")
|
flash("Beitrag nicht gefunden.", "error")
|
||||||
@@ -607,40 +764,7 @@ def blog_post(post_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def my_licenses():
|
def my_licenses():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
action = _sanitize_text(request.form.get("action") or "", 30)
|
flash("Weitergabe von Lizenzen ist deaktiviert.", "error")
|
||||||
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()
|
|
||||||
return redirect(url_for("my_licenses"))
|
return redirect(url_for("my_licenses"))
|
||||||
|
|
||||||
licenses = []
|
licenses = []
|
||||||
@@ -656,8 +780,7 @@ def my_licenses():
|
|||||||
if client:
|
if client:
|
||||||
client.close()
|
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)
|
||||||
return render_template("my_licenses.html", licenses=licenses, transfer_users=transfer_users)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/my/invoices')
|
@app.route('/my/invoices')
|
||||||
@@ -945,24 +1068,6 @@ def admin_licenses():
|
|||||||
)
|
)
|
||||||
flash("Lizenz aktualisiert.", "success")
|
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:
|
elif action == "delete" and license_id:
|
||||||
col.delete_one({"_id": ObjectId(license_id)})
|
col.delete_one({"_id": ObjectId(license_id)})
|
||||||
flash("Lizenz geloescht.", "success")
|
flash("Lizenz geloescht.", "success")
|
||||||
|
|||||||
@@ -48,6 +48,76 @@
|
|||||||
margin-bottom: 0.8rem;
|
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 {
|
.appointment-card {
|
||||||
border: 1px solid #d8e3ec;
|
border: 1px solid #d8e3ec;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -141,6 +211,10 @@
|
|||||||
.admin-grid {
|
.admin-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@@ -175,12 +249,49 @@
|
|||||||
|
|
||||||
<section class="appointments-section">
|
<section class="appointments-section">
|
||||||
<h2>Terminanfragen verwalten</h2>
|
<h2>Terminanfragen verwalten</h2>
|
||||||
|
<div class="block-days-section">
|
||||||
|
<h3>Kalender-Tage sperren</h3>
|
||||||
|
<form method="POST" action="{{ url_for('admin_block_day') }}" class="block-form">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<input type="date" name="block_date" required>
|
||||||
|
<input type="text" name="reason" placeholder="Grund (optional)">
|
||||||
|
<button type="submit" class="block-btn">Tag sperren</button>
|
||||||
|
</form>
|
||||||
|
<div class="blocked-list">
|
||||||
|
{% if blocked_days %}
|
||||||
|
{% for blocked in blocked_days %}
|
||||||
|
<div class="blocked-item">
|
||||||
|
<p>
|
||||||
|
<strong>{{ blocked.date }}</strong>
|
||||||
|
{% if blocked.reason %}
|
||||||
|
- {{ blocked.reason }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ url_for('admin_block_day') }}">
|
||||||
|
<input type="hidden" name="action" value="remove">
|
||||||
|
<input type="hidden" name="block_date" value="{{ blocked.date }}">
|
||||||
|
<button type="submit" class="unblock-btn">Entsperren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>Aktuell sind keine Tage gesperrt.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if appointments %}
|
{% if appointments %}
|
||||||
{% for appointment in appointments %}
|
{% for appointment in appointments %}
|
||||||
<article class="appointment-card">
|
<article class="appointment-card">
|
||||||
<div class="appointment-info">
|
<div class="appointment-info">
|
||||||
<strong>{{ appointment.display_name }} - {{ appointment.subject }}</strong>
|
<strong>{{ appointment.display_name }} - {{ appointment.subject }}</strong>
|
||||||
<p><strong>Datum:</strong> {{ appointment.date }} um {{ appointment.time }}</p>
|
<p><strong>Datum:</strong> {{ appointment.date }} um {{ appointment.time }}</p>
|
||||||
|
<p><strong>Terminart:</strong> {{ appointment.meeting_label or ('Vor Ort' if appointment.meeting_type == 'vor_ort' else 'Digital') }}</p>
|
||||||
|
{% if appointment.location_name %}
|
||||||
|
<p><strong>Ort:</strong> {{ appointment.location_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if appointment.location_maps_url %}
|
||||||
|
<p><strong>Maps:</strong> <a href="{{ appointment.location_maps_url }}" target="_blank" rel="noopener noreferrer">Link öffnen</a></p>
|
||||||
|
{% endif %}
|
||||||
<p><strong>Angefragt:</strong> {{ appointment.created_at[:10] }}</p>
|
<p><strong>Angefragt:</strong> {{ appointment.created_at[:10] }}</p>
|
||||||
{% if appointment.note %}
|
{% if appointment.note %}
|
||||||
<p><strong>Notiz:</strong> {{ appointment.note }}</p>
|
<p><strong>Notiz:</strong> {{ appointment.note }}</p>
|
||||||
|
|||||||
@@ -45,19 +45,6 @@
|
|||||||
<input type="text" name="valid_until" value="{{ item.valid_until }}" placeholder="Gültig bis">
|
<input type="text" name="valid_until" value="{{ item.valid_until }}" placeholder="Gültig bis">
|
||||||
<button type="submit">Aktualisieren</button>
|
<button type="submit">Aktualisieren</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" class="inline-form" style="margin-top:0.45rem;">
|
|
||||||
<input type="hidden" name="action" value="transfer">
|
|
||||||
<input type="hidden" name="license_id" value="{{ item.id }}">
|
|
||||||
<select name="target_username" required>
|
|
||||||
<option value="">Lizenz weitergeben an...</option>
|
|
||||||
{% for user in users %}
|
|
||||||
{% if user.username != item.username %}
|
|
||||||
<option value="{{ user.username }}">{{ user.username }} ({{ user.display_name }})</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit">Weitergeben</button>
|
|
||||||
</form>
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="action" value="delete">
|
<input type="hidden" name="action" value="delete">
|
||||||
<input type="hidden" name="license_id" value="{{ item.id }}">
|
<input type="hidden" name="license_id" value="{{ item.id }}">
|
||||||
|
|||||||
@@ -94,6 +94,18 @@
|
|||||||
color: #ffffff;
|
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,
|
.booking-form h2,
|
||||||
.appointment-list h2 {
|
.appointment-list h2 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -124,7 +136,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
.field textarea {
|
.field textarea,
|
||||||
|
.field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #c4d4e0;
|
border: 1px solid #c4d4e0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -180,6 +193,41 @@
|
|||||||
background: #eef4f8;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.appointments-grid {
|
.appointments-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -216,10 +264,15 @@
|
|||||||
<button type="button" class="day empty" disabled></button>
|
<button type="button" class="day empty" disabled></button>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set day_iso = "%04d-%02d-%02d"|format(year, month, day) %}
|
{% set day_iso = "%04d-%02d-%02d"|format(year, month, day) %}
|
||||||
|
{% set blocked_day = blocked_day_map.get(day_iso) %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="day {% if day_iso == today_iso %}today{% endif %}"
|
class="day {% if day_iso == today_iso %}today{% endif %} {% if blocked_day %}blocked{% endif %}"
|
||||||
data-date="{{ day_iso }}"
|
data-date="{{ day_iso }}"
|
||||||
|
{% if blocked_day %}
|
||||||
|
disabled
|
||||||
|
title="Gesperrt{% if blocked_day.reason %}: {{ blocked_day.reason }}{% endif %}"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ day }}
|
{{ day }}
|
||||||
</button>
|
</button>
|
||||||
@@ -231,6 +284,7 @@
|
|||||||
|
|
||||||
<aside class="panel booking-form">
|
<aside class="panel booking-form">
|
||||||
<h2>Termin anfragen</h2>
|
<h2>Termin anfragen</h2>
|
||||||
|
<p class="meeting-note">Persoenliches Gespraechstermin: Digital oder Vor Ort.</p>
|
||||||
<span class="selected-date-badge" id="selectedDateBadge">Kein Datum gewählt</span>
|
<span class="selected-date-badge" id="selectedDateBadge">Kein Datum gewählt</span>
|
||||||
<form method="POST" action="{{ url_for('appointments', month=month, year=year) }}">
|
<form method="POST" action="{{ url_for('appointments', month=month, year=year) }}">
|
||||||
<input type="hidden" id="selectedDateInput" name="selected_date" required>
|
<input type="hidden" id="selectedDateInput" name="selected_date" required>
|
||||||
@@ -238,10 +292,26 @@
|
|||||||
<label for="appointment_time">Uhrzeit</label>
|
<label for="appointment_time">Uhrzeit</label>
|
||||||
<input id="appointment_time" name="appointment_time" type="time" required>
|
<input id="appointment_time" name="appointment_time" type="time" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="meeting_type">Terminart</label>
|
||||||
|
<select id="meeting_type" name="meeting_type" required>
|
||||||
|
<option value="digital">Digital</option>
|
||||||
|
<option value="vor_ort">Vor Ort</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="subject">Betreff</label>
|
<label for="subject">Betreff</label>
|
||||||
<input id="subject" name="subject" type="text" placeholder="z. B. Projekt Kickoff" required>
|
<input id="subject" name="subject" type="text" placeholder="z. B. Projekt Kickoff" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" id="locationField" style="display: none;">
|
||||||
|
<label for="location_name">Ort (bei Vor-Ort Pflicht)</label>
|
||||||
|
<input id="location_name" name="location_name" type="text" placeholder="Adresse oder Ort">
|
||||||
|
<div class="maps-tools">
|
||||||
|
<button type="button" id="mapsSearchBtn" class="maps-btn">In Google Maps suchen</button>
|
||||||
|
</div>
|
||||||
|
<div class="helper-line">Optional: Google-Maps-Link eintragen.</div>
|
||||||
|
<input id="location_maps_url" name="location_maps_url" type="url" placeholder="https://maps.google.com/..." style="margin-top: 0.4rem;">
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="note">Notiz (optional)</label>
|
<label for="note">Notiz (optional)</label>
|
||||||
<textarea id="note" name="note" placeholder="Kurzbeschreibung der Anfrage"></textarea>
|
<textarea id="note" name="note" placeholder="Kurzbeschreibung der Anfrage"></textarea>
|
||||||
@@ -260,6 +330,13 @@
|
|||||||
{% if item.note %}
|
{% if item.note %}
|
||||||
<p>{{ item.note }}</p>
|
<p>{{ item.note }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<p class="entry-meta"><strong>Terminart:</strong> {{ item.meeting_label or ('Vor Ort' if item.meeting_type == 'vor_ort' else 'Digital') }}</p>
|
||||||
|
{% if item.location_name %}
|
||||||
|
<p class="entry-meta"><strong>Ort:</strong> {{ item.location_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location_maps_url %}
|
||||||
|
<p class="entry-meta"><a href="{{ item.location_maps_url }}" target="_blank" rel="noopener noreferrer">Google Maps anzeigen</a></p>
|
||||||
|
{% endif %}
|
||||||
{% if item.response %}
|
{% if item.response %}
|
||||||
<p><strong>Admin-Antwort:</strong> {{ item.response }}</p>
|
<p><strong>Admin-Antwort:</strong> {{ item.response }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -276,6 +353,10 @@
|
|||||||
const dayGrid = document.getElementById("dayGrid");
|
const dayGrid = document.getElementById("dayGrid");
|
||||||
const selectedDateInput = document.getElementById("selectedDateInput");
|
const selectedDateInput = document.getElementById("selectedDateInput");
|
||||||
const selectedDateBadge = document.getElementById("selectedDateBadge");
|
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) {
|
function setSelection(dateValue, button) {
|
||||||
selectedDateInput.value = dateValue;
|
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) {
|
dayGrid.addEventListener("click", function (event) {
|
||||||
const target = event.target.closest(".day[data-date]");
|
const target = event.target.closest(".day[data-date]");
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -297,6 +388,16 @@
|
|||||||
}
|
}
|
||||||
setSelection(target.dataset.date, target);
|
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();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+162
-3
@@ -42,6 +42,12 @@
|
|||||||
var(--bg-main);
|
var(--bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||||
@@ -210,6 +216,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-wrap {
|
.main-wrap {
|
||||||
|
flex: 1 0 auto;
|
||||||
padding: 2rem 0 3.4rem;
|
padding: 2rem 0 3.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,22 +361,66 @@
|
|||||||
.site-footer {
|
.site-footer {
|
||||||
background: linear-gradient(135deg, var(--brand-strong) 0%, #0a4c74 100%);
|
background: linear-gradient(135deg, var(--brand-strong) 0%, #0a4c74 100%);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
margin-top: 4rem;
|
margin-top: auto;
|
||||||
padding: 3rem 0 1.5rem;
|
padding: 3rem 0 1.5rem;
|
||||||
border-top: 1px solid var(--line);
|
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 {
|
.footer-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-section {
|
.footer-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
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 {
|
.footer-section h4 {
|
||||||
@@ -395,11 +446,12 @@
|
|||||||
.footer-links a {
|
.footer-links a {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-links a:hover {
|
.footer-links a:hover {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-bottom {
|
.footer-bottom {
|
||||||
@@ -414,8 +466,63 @@
|
|||||||
margin: 0;
|
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) {
|
@media (max-width: 700px) {
|
||||||
.footer-inner {
|
.footer-inner {
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -425,5 +532,57 @@
|
|||||||
<!-- Mobile compatibility scripts -->
|
<!-- Mobile compatibility scripts -->
|
||||||
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var footer = document.querySelector(".site-footer");
|
||||||
|
if (!footer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = document.body;
|
||||||
|
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
var ticking = false;
|
||||||
|
|
||||||
|
function updateFooterMode() {
|
||||||
|
ticking = false;
|
||||||
|
var maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||||
|
|
||||||
|
body.classList.remove("footer-scroll-mode", "footer-reveal");
|
||||||
|
|
||||||
|
if (reduceMotion.matches || maxScroll < 140) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.classList.add("footer-scroll-mode");
|
||||||
|
|
||||||
|
var currentScroll = window.scrollY || window.pageYOffset || 0;
|
||||||
|
var distanceToBottom = maxScroll - currentScroll;
|
||||||
|
var progress = maxScroll > 0 ? currentScroll / maxScroll : 1;
|
||||||
|
var shouldReveal = progress >= 0.78 || distanceToBottom <= 320;
|
||||||
|
|
||||||
|
body.classList.toggle("footer-reveal", shouldReveal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestUpdate() {
|
||||||
|
if (ticking) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ticking = true;
|
||||||
|
window.requestAnimationFrame(updateFooterMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", requestUpdate, { passive: true });
|
||||||
|
window.addEventListener("resize", requestUpdate);
|
||||||
|
window.addEventListener("load", requestUpdate);
|
||||||
|
|
||||||
|
if (typeof reduceMotion.addEventListener === "function") {
|
||||||
|
reduceMotion.addEventListener("change", requestUpdate);
|
||||||
|
} else if (typeof reduceMotion.addListener === "function") {
|
||||||
|
reduceMotion.addListener(requestUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestUpdate();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -17,17 +17,6 @@
|
|||||||
<p><strong>Lizenzschlüssel:</strong> {{ item.license_key }}</p>
|
<p><strong>Lizenzschlüssel:</strong> {{ item.license_key }}</p>
|
||||||
<p><strong>Status:</strong> {{ item.status }}</p>
|
<p><strong>Status:</strong> {{ item.status }}</p>
|
||||||
<p><strong>Gültig bis:</strong> {{ item.valid_until }}</p>
|
<p><strong>Gültig bis:</strong> {{ item.valid_until }}</p>
|
||||||
<form method="post" class="transfer-form">
|
|
||||||
<input type="hidden" name="action" value="transfer">
|
|
||||||
<input type="hidden" name="license_id" value="{{ item.id }}">
|
|
||||||
<select name="target_username" required>
|
|
||||||
<option value="">Lizenz weitergeben an...</option>
|
|
||||||
{% for user in transfer_users %}
|
|
||||||
<option value="{{ user.username }}">{{ user.username }} ({{ user.display_name }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit">Weitergeben</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -43,8 +32,5 @@
|
|||||||
.grid { display: grid; gap: 0.8rem; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
|
.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 { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
|
||||||
.card p { margin-top: 0.35rem; }
|
.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; }
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+12
-12
@@ -43,7 +43,7 @@ def check_nm_pwd(username, password):
|
|||||||
dict: User document if credentials are valid, None otherwise
|
dict: User document if credentials are valid, None otherwise
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
hashed_password = hashlib.sha512(password.encode()).hexdigest()
|
hashed_password = hashlib.sha512(password.encode()).hexdigest()
|
||||||
user = users.find_one({'Username': username, 'Password': hashed_password})
|
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
|
bool: True if user was added successfully, False if password was too weak
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
if not check_password_strength(password):
|
if not check_password_strength(password):
|
||||||
return False
|
return False
|
||||||
@@ -83,7 +83,7 @@ def make_admin(username):
|
|||||||
bool: True if user was promoted successfully
|
bool: True if user was promoted successfully
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
users.update_one({'Username': username}, {'$set': {'Admin': True}})
|
users.update_one({'Username': username}, {'$set': {'Admin': True}})
|
||||||
client.close()
|
client.close()
|
||||||
@@ -100,7 +100,7 @@ def remove_admin(username):
|
|||||||
bool: True if user was demoted successfully
|
bool: True if user was demoted successfully
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
users.update_one({'Username': username}, {'$set': {'Admin': False}})
|
users.update_one({'Username': username}, {'$set': {'Admin': False}})
|
||||||
client.close()
|
client.close()
|
||||||
@@ -117,7 +117,7 @@ def get_user(username):
|
|||||||
dict: User document or None if not found
|
dict: User document or None if not found
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
users_return = users.find_one({'Username': username})
|
users_return = users.find_one({'Username': username})
|
||||||
client.close()
|
client.close()
|
||||||
@@ -135,7 +135,7 @@ def check_admin(username):
|
|||||||
bool: True if user is an administrator, False otherwise
|
bool: True if user is an administrator, False otherwise
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
user = users.find_one({'Username': username})
|
user = users.find_one({'Username': username})
|
||||||
client.close()
|
client.close()
|
||||||
@@ -153,7 +153,7 @@ def delete_user(username):
|
|||||||
bool: True if user was deleted successfully, False otherwise
|
bool: True if user was deleted successfully, False otherwise
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
result = users.delete_one({'username': username})
|
result = users.delete_one({'username': username})
|
||||||
client.close()
|
client.close()
|
||||||
@@ -175,7 +175,7 @@ def get_name(username):
|
|||||||
str: String of name
|
str: String of name
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
user = users.find_one({'Username': username})
|
user = users.find_one({'Username': username})
|
||||||
name = user.get("name")
|
name = user.get("name")
|
||||||
@@ -189,7 +189,7 @@ def get_last_name(username):
|
|||||||
str: String of last_name
|
str: String of last_name
|
||||||
"""
|
"""
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
user = users.find_one({'Username': username})
|
user = users.find_one({'Username': username})
|
||||||
name = user.get("last_name")
|
name = user.get("last_name")
|
||||||
@@ -206,7 +206,7 @@ def get_all_users():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
all_users = list(users.find())
|
all_users = list(users.find())
|
||||||
client.close()
|
client.close()
|
||||||
@@ -230,7 +230,7 @@ def update_password(username, new_password):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
|
|
||||||
# Hash the new password
|
# Hash the new password
|
||||||
@@ -262,7 +262,7 @@ def update_user_name(username, name, last_name):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient('localhost', 27017)
|
client = MongoClient('localhost', 27017)
|
||||||
db = client['Inventarsystem']
|
db = client['Invario_Website']
|
||||||
users = db['users']
|
users = db['users']
|
||||||
|
|
||||||
result = users.update_one(
|
result = users.update_one(
|
||||||
|
|||||||
Reference in New Issue
Block a user