Refactor code structure for improved readability and maintainability
This commit is contained in:
+97
-116
@@ -114,7 +114,7 @@ def _sanitize_text(text: str, max_length: int = 255) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _activate_test_license_for_user(username: str, school_name: str) -> tuple[bool, str]:
|
||||
def _activate_test_license_for_user(username: str, school_name: str, package_name: str = "Normal") -> tuple[bool, str]:
|
||||
"""Create a one-time test license for the user if none exists yet."""
|
||||
user_name = _sanitize_text(username or "", 80)
|
||||
school = _sanitize_text(school_name or "", 200)
|
||||
@@ -125,11 +125,15 @@ def _activate_test_license_for_user(username: str, school_name: str) -> tuple[bo
|
||||
try:
|
||||
client, col = _get_collection("licenses")
|
||||
|
||||
normalized_package = _sanitize_text(package_name or "Normal", 40)
|
||||
if normalized_package not in {"Normal", "Pro", "Buecherei", "Bücherei"}:
|
||||
normalized_package = "Normal"
|
||||
|
||||
existing = col.find_one(
|
||||
{
|
||||
"username": user_name,
|
||||
"plan": "Test",
|
||||
"status": {"$in": ["Aktiv", "Pausiert"]},
|
||||
"plan": {"$regex": "^Test"},
|
||||
}
|
||||
)
|
||||
if existing:
|
||||
@@ -141,7 +145,7 @@ def _activate_test_license_for_user(username: str, school_name: str) -> tuple[bo
|
||||
"username": user_name,
|
||||
"school_name": school or user_name,
|
||||
"license_key": test_key,
|
||||
"plan": "Test",
|
||||
"plan": f"Test {normalized_package}",
|
||||
"status": "Aktiv",
|
||||
"valid_until": (datetime.utcnow() + timedelta(days=30)).date().isoformat(),
|
||||
"hwid_uuid": "",
|
||||
@@ -245,6 +249,19 @@ def _validate_username(username: str) -> bool:
|
||||
return all(c.isalnum() or c in "_-" for c in username)
|
||||
|
||||
|
||||
def _validate_email(email: str) -> bool:
|
||||
"""Simple email validation for registration input."""
|
||||
value = (email or "").strip()
|
||||
if len(value) < 5 or len(value) > 254:
|
||||
return False
|
||||
if "@" not in value or value.count("@") != 1:
|
||||
return False
|
||||
local, domain = value.split("@", 1)
|
||||
if not local or not domain or "." not in domain:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _find_user(username: str):
|
||||
username_key = (username or "").strip()
|
||||
if not username_key:
|
||||
@@ -383,11 +400,12 @@ def register():
|
||||
username = (request.form.get("username") or "").strip()
|
||||
school_name = (request.form.get("school_name") or "").strip()
|
||||
contact_person = (request.form.get("contact_person") or "").strip()
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
activate_test_key = (request.form.get("activate_test_key") or "").strip().lower() in {"1", "on", "true", "yes"}
|
||||
password = request.form.get("password") or ""
|
||||
password_repeat = request.form.get("password_repeat") or ""
|
||||
|
||||
if not username or not school_name or not contact_person or not password or not password_repeat:
|
||||
if not username or not school_name or not contact_person or not email or not password or not password_repeat:
|
||||
flash("Bitte alle Felder ausfuellen.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
@@ -407,13 +425,18 @@ def register():
|
||||
flash("Benutzername bereits vergeben.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
if not _validate_email(email):
|
||||
flash("Bitte eine gueltige E-Mail-Adresse angeben.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
school_name = _sanitize_text(school_name, 120)
|
||||
contact_person = _sanitize_text(contact_person, 120)
|
||||
email = _sanitize_text(email, 254)
|
||||
|
||||
try:
|
||||
existing_users = user_store.get_all_users() or []
|
||||
is_first_user = len(existing_users) == 0
|
||||
if not user_store.add_user(username, password, school_name, contact_person):
|
||||
if not user_store.add_user(username, password, school_name, contact_person, email):
|
||||
flash("Benutzer konnte nicht erstellt werden.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
@@ -436,134 +459,92 @@ def register():
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@app.route('/appointments', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@app.route('/appointments', methods=['GET'])
|
||||
def appointments():
|
||||
today = date.today()
|
||||
month = request.args.get("month", type=int) or today.month
|
||||
year = request.args.get("year", type=int) or today.year
|
||||
|
||||
month = 1 if month < 1 else 12 if month > 12 else month
|
||||
year = 1970 if year < 1970 else year
|
||||
|
||||
if request.method == 'POST':
|
||||
selected_date = (request.form.get("selected_date") or "").strip()
|
||||
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)
|
||||
except ValueError:
|
||||
flash("Bitte eine gueltige Buchung 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 eine andere Buchung 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 Buchungsart waehlen.", "error")
|
||||
return redirect(url_for("appointments", month=month, year=year))
|
||||
|
||||
if meeting_type == "vor_ort" and not location_name:
|
||||
flash("Bei Vor-Ort-Buchungen 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)
|
||||
software_packages = [
|
||||
{
|
||||
"slug": "normal",
|
||||
"name": "Normal",
|
||||
"headline": "Stabiler Einstieg fuer den Schulalltag",
|
||||
"features": [
|
||||
"Inventarverwaltung mit Rollenrechten",
|
||||
"Basis-Support und Ticketing",
|
||||
"Schulweite Uebersichten",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "pro",
|
||||
"name": "Pro",
|
||||
"headline": "Fuer Schulen mit erweitertem Bedarf",
|
||||
"features": [
|
||||
"Erweiterte Lizenzverwaltung",
|
||||
"Detailberichte und Admin-Insights",
|
||||
"Priorisierter Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "buecherei",
|
||||
"name": "Bücherei",
|
||||
"headline": "Optimiert fuer Bibliothek und Medien",
|
||||
"features": [
|
||||
"Ausleih- und Rueckgabeprozesse",
|
||||
"Bestandsuebersichten fuer Medien",
|
||||
"Transparente Historie pro Medium",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
active_test_license = None
|
||||
if session.get("username"):
|
||||
client = None
|
||||
try:
|
||||
client, col = _get_collection("appointments")
|
||||
col.insert_one(
|
||||
client, col = _get_collection("licenses")
|
||||
active_test_license = col.find_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",
|
||||
}
|
||||
"status": {"$in": ["Aktiv", "Pausiert"]},
|
||||
"plan": {"$regex": "^Test"},
|
||||
},
|
||||
{"_id": 0},
|
||||
)
|
||||
except PyMongoError:
|
||||
flash("Buchung konnte nicht gespeichert werden.", "error")
|
||||
return redirect(url_for("appointments", month=month, year=year))
|
||||
active_test_license = None
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
flash("Buchung erfolgreich angefragt.", "success")
|
||||
selected = date.fromisoformat(selected_date)
|
||||
return redirect(url_for("appointments", month=selected.month, year=selected.year))
|
||||
|
||||
cal = calendar.Calendar(firstweekday=0)
|
||||
month_grid = cal.monthdayscalendar(year, month)
|
||||
month_name = calendar.month_name[month]
|
||||
|
||||
previous_month = month - 1
|
||||
previous_year = year
|
||||
if previous_month == 0:
|
||||
previous_month = 12
|
||||
previous_year -= 1
|
||||
|
||||
next_month = month + 1
|
||||
next_year = year
|
||||
if next_month == 13:
|
||||
next_month = 1
|
||||
next_year += 1
|
||||
|
||||
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("Buchungen konnten nicht geladen werden.", "error")
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
blocked_day_map = _get_blocked_day_map()
|
||||
|
||||
return render_template(
|
||||
"appointments.html",
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
year=year,
|
||||
month_grid=month_grid,
|
||||
today_iso=today.isoformat(),
|
||||
previous_month=previous_month,
|
||||
previous_year=previous_year,
|
||||
next_month=next_month,
|
||||
next_year=next_year,
|
||||
blocked_day_map=blocked_day_map,
|
||||
user_appointments=user_appointments,
|
||||
software_packages=software_packages,
|
||||
active_test_license=active_test_license,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/appointments/start-test', methods=['POST'])
|
||||
@login_required
|
||||
def start_test_package():
|
||||
package_raw = _sanitize_text(request.form.get("package") or "normal", 40).lower()
|
||||
package_map = {
|
||||
"normal": "Normal",
|
||||
"pro": "Pro",
|
||||
"buecherei": "Buecherei",
|
||||
}
|
||||
selected_package = package_map.get(package_raw)
|
||||
if not selected_package:
|
||||
flash("Ungueltiges Paket ausgewaehlt.", "error")
|
||||
return redirect(url_for("appointments"))
|
||||
|
||||
school_name = _sanitize_text(session.get("display_name") or session.get("username") or "", 200)
|
||||
activated, message = _activate_test_license_for_user(session.get("username") or "", school_name, selected_package)
|
||||
|
||||
if activated:
|
||||
flash(f"Testversion fuer Paket {selected_package} gestartet. Key: {message}", "success")
|
||||
else:
|
||||
flash(message, "error")
|
||||
|
||||
return redirect(url_for("appointments"))
|
||||
|
||||
|
||||
@app.route('/admin/dashboard')
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
+117
-375
@@ -1,235 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Buchungsoption{% endblock %}
|
||||
{% block title %}Buchungsoptionen{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.appointments-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
.packages-hero {
|
||||
background: linear-gradient(140deg, #f6f2e6 0%, #e8efe4 55%, #dfe9ef 100%);
|
||||
border: 1px solid #ced8d1;
|
||||
border-radius: 18px;
|
||||
padding: 1.4rem;
|
||||
box-shadow: 0 14px 34px rgba(16, 36, 47, 0.08);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
.packages-hero h1 {
|
||||
color: #173f57;
|
||||
font-size: clamp(1.65rem, 3.2vw, 2.45rem);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.packages-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d5e0e8;
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 10px 24px rgba(23, 44, 57, 0.05);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
margin: 0;
|
||||
color: #184560;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.package-headline {
|
||||
color: #3a6177;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: #456274;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.package-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #0d4f77;
|
||||
background: linear-gradient(120deg, #0d6294 0%, #0b476a 100%);
|
||||
color: #ffffff;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
border-color: #9ab5c8;
|
||||
background: #f2f8fc;
|
||||
color: #275a78;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-top: 1rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem;
|
||||
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem;
|
||||
color: #274f65;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.9rem;
|
||||
.notice strong {
|
||||
color: #173f57;
|
||||
}
|
||||
|
||||
.calendar-header h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #133f5d;
|
||||
}
|
||||
|
||||
.month-nav {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.month-nav a {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #bfd1de;
|
||||
padding: 0.38rem 0.7rem;
|
||||
color: #185170;
|
||||
font-weight: 700;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.weekday-row,
|
||||
.day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #587689;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.day {
|
||||
min-height: 74px;
|
||||
border: 1px solid #d8e3ec;
|
||||
border-radius: 10px;
|
||||
background: #f9fcff;
|
||||
color: #0f3f5d;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
border-color: #9ec0d6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.day.empty {
|
||||
background: #f2f6f9;
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
border-color: #2d7fab;
|
||||
}
|
||||
|
||||
.day.selected {
|
||||
background: linear-gradient(140deg, #0d5c88 0%, #0a4464 100%);
|
||||
border-color: #0d5c88;
|
||||
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;
|
||||
color: #143f5e;
|
||||
margin: 0 0 0.8rem;
|
||||
}
|
||||
|
||||
.selected-date-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.8rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.6rem;
|
||||
border: 1px solid #bfd2df;
|
||||
color: #23526c;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 700;
|
||||
color: #1f4b65;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
border: 1px solid #c4d4e0;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.72rem;
|
||||
font: inherit;
|
||||
color: #143f5d;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 86px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.74rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entry {
|
||||
border: 1px solid #d7e2ea;
|
||||
border-radius: 12px;
|
||||
padding: 0.72rem;
|
||||
margin-bottom: 0.55rem;
|
||||
background: #f9fbfd;
|
||||
}
|
||||
|
||||
.entry strong {
|
||||
color: #113d59;
|
||||
}
|
||||
|
||||
.entry p {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
margin-top: 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c9d7e2;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
color: #2f5f79;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
@media (max-width: 980px) {
|
||||
.packages-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -237,167 +101,45 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="appointments-grid">
|
||||
<article class="panel">
|
||||
<div class="calendar-header">
|
||||
<h1>{{ month_name }} {{ year }}</h1>
|
||||
<div class="month-nav">
|
||||
<a href="{{ url_for('appointments', month=previous_month, year=previous_year) }}">Zurück</a>
|
||||
<a href="{{ url_for('appointments', month=next_month, year=next_year) }}">Weiter</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weekday-row" aria-hidden="true">
|
||||
<div class="weekday">Mo</div>
|
||||
<div class="weekday">Di</div>
|
||||
<div class="weekday">Mi</div>
|
||||
<div class="weekday">Do</div>
|
||||
<div class="weekday">Fr</div>
|
||||
<div class="weekday">Sa</div>
|
||||
<div class="weekday">So</div>
|
||||
</div>
|
||||
|
||||
<div class="day-grid" id="dayGrid">
|
||||
{% for week in month_grid %}
|
||||
{% for day in week %}
|
||||
{% if day == 0 %}
|
||||
<button type="button" class="day empty" disabled></button>
|
||||
{% else %}
|
||||
{% set day_iso = "%04d-%02d-%02d"|format(year, month, day) %}
|
||||
{% set blocked_day = blocked_day_map.get(day_iso) %}
|
||||
<button
|
||||
type="button"
|
||||
class="day {% if day_iso == today_iso %}today{% endif %} {% if blocked_day %}blocked{% endif %}"
|
||||
data-date="{{ day_iso }}"
|
||||
{% if blocked_day %}
|
||||
disabled
|
||||
title="Gesperrt{% if blocked_day.reason %}: {{ blocked_day.reason }}{% endif %}"
|
||||
{% endif %}
|
||||
>
|
||||
{{ day }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="panel booking-form">
|
||||
<h2>Buchung anfragen</h2>
|
||||
<p class="meeting-note">Persoenliche Buchungsoption: Digital oder Vor Ort.</p>
|
||||
<span class="selected-date-badge" id="selectedDateBadge">Kein Datum gewählt</span>
|
||||
<form method="POST" action="{{ url_for('appointments', month=month, year=year) }}">
|
||||
<input type="hidden" id="selectedDateInput" name="selected_date" required>
|
||||
<div class="field">
|
||||
<label for="appointment_time">Uhrzeit</label>
|
||||
<input id="appointment_time" name="appointment_time" type="time" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="meeting_type">Buchungsart</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">
|
||||
<label for="subject">Betreff</label>
|
||||
<input id="subject" name="subject" type="text" placeholder="z. B. Projekt Kickoff" required>
|
||||
</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">
|
||||
<label for="note">Notiz (optional)</label>
|
||||
<textarea id="note" name="note" placeholder="Kurzbeschreibung der Anfrage"></textarea>
|
||||
</div>
|
||||
<button class="submit-btn" type="submit">Buchung anfragen</button>
|
||||
</form>
|
||||
</aside>
|
||||
<section class="packages-hero">
|
||||
<h1>Buchungsoptionen als Softwarepakete</h1>
|
||||
<p>Alle Pakete sind frei einsehbar, auch ohne Admin. Die Testversion kann nur mit aktivem Konto gestartet werden.</p>
|
||||
<p>Jedes Paket basiert auf derselben Sicherheitsarchitektur mit Rollenrechten, geschuetzten Sessions und nachvollziehbaren Aktionen.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel appointment-list" style="margin-top: 1rem;">
|
||||
<h2>Meine Buchungsanfragen</h2>
|
||||
{% if user_appointments %}
|
||||
{% for item in user_appointments %}
|
||||
<article class="entry">
|
||||
<strong>{{ item.date }} um {{ item.time }} - {{ item.subject }}</strong>
|
||||
{% if item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
<section class="packages-grid">
|
||||
{% for package in software_packages %}
|
||||
<article class="package-card">
|
||||
<h2 class="package-name">{{ package.name }}</h2>
|
||||
<p class="package-headline">{{ package.headline }}</p>
|
||||
<ul class="feature-list">
|
||||
{% for feature in package.features %}
|
||||
<li>{{ feature }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="package-actions">
|
||||
{% if 'username' in session %}
|
||||
<form method="POST" action="{{ url_for('start_test_package') }}">
|
||||
<input type="hidden" name="package" value="{{ package.slug }}">
|
||||
<button class="btn" type="submit">Testversion starten</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto starten</a>
|
||||
{% endif %}
|
||||
<p class="entry-meta"><strong>Buchungsart:</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 %}
|
||||
<p><strong>Admin-Antwort:</strong> {{ item.response }}</p>
|
||||
{% endif %}
|
||||
<span class="status">{{ item.status }}</span>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="notice">
|
||||
{% if 'username' in session %}
|
||||
{% if active_test_license %}
|
||||
<p><strong>Aktive Testversion:</strong> {{ active_test_license.plan }} | Key: {{ active_test_license.license_key }} | Gueltig bis: {{ active_test_license.valid_until }}</p>
|
||||
{% else %}
|
||||
<p><strong>Hinweis:</strong> Fuer Ihr Konto ist noch keine Testversion aktiv. Starten Sie oben eine Testversion fuer Normal, Pro oder Buecherei.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Noch keine Anfragen vorhanden.</p>
|
||||
<p><strong>Hinweis:</strong> Bitte einloggen oder registrieren, um die Testversion zu starten.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
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;
|
||||
selectedDateBadge.textContent = "Ausgewählt: " + dateValue;
|
||||
|
||||
dayGrid.querySelectorAll(".day").forEach(function (node) {
|
||||
node.classList.remove("selected");
|
||||
});
|
||||
|
||||
if (button) {
|
||||
button.classList.add("selected");
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
+85
-30
@@ -268,6 +268,41 @@
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.logo-wall {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #d4ddd4;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 30px rgba(28, 45, 41, 0.05);
|
||||
}
|
||||
|
||||
.logo-wall h2 {
|
||||
color: #21444a;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.logo-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.logo-item {
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 12px;
|
||||
background: #f8faf7;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.logo-item img {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -313,6 +348,10 @@
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.logo-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -320,10 +359,10 @@
|
||||
{% block content %}
|
||||
<section class="hero" id="top">
|
||||
<div>
|
||||
<span class="eyebrow">Für moderne Schulverwaltung</span>
|
||||
<h1>Mehr Ruhe im Schulalltag durch klare digitale Verwaltung.</h1>
|
||||
<span class="eyebrow">Lehrmittelverwaltungssoftware für Schulen</span>
|
||||
<h1>Lehrmittel, Schul-IT und Ausleihen in einem sicheren System.</h1>
|
||||
<p>
|
||||
Invario unterstützt Bildungseinrichtungen in Deutschland dabei, Inventar, Lizenzen und Anfragen nachvollziehbar zu organisieren. Die Bedienung bleibt bewusst einfach, während zentrale Sicherheitsfunktionen wie Rollenrechte, Protokollierung und geschützte Verbindungen standardmäßig integriert sind.
|
||||
Invario ist eine Lehrmittelverwaltungssoftware, die Schulbibliothek, Inventar und digitale Ausleihe zusammenfuehrt. Prozesse bleiben bewusst schlank und schnell, waehrend Sicherheitsfunktionen wie rollenbasierte Rechte, Sitzungsabsicherung und nachvollziehbare Protokolle im Hintergrund dauerhaft aktiv sind.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
{% if 'username' in session %}
|
||||
@@ -348,71 +387,87 @@
|
||||
</section>
|
||||
|
||||
<section class="section" id="nutzen" style="animation-delay: 100ms;">
|
||||
<h2>Vorteile für Ihr Sekretariat</h2>
|
||||
<p>Alle Informationen sind gebündelt, Vorgänge sind standardisiert, und Rückfragen lassen sich schneller beantworten.</p>
|
||||
<h2>Vorteile fuer Verwaltung und Bibliothek</h2>
|
||||
<p>Alle Daten zu Lehrmitteln, Geraeten und Ausleihen sind gebuendelt, sodass Rückfragen schnell geklaert werden koennen.</p>
|
||||
<div class="section-grid">
|
||||
<article class="card">
|
||||
<h3>Inventar auf einen Blick</h3>
|
||||
<p>Geräte, Standorte und Zuständigkeiten sind zentral erfasst. Das reduziert Suchzeiten und doppelte Rückfragen.</p>
|
||||
<h3>Lehrmittel pro Klasse steuern</h3>
|
||||
<p>Buecher und Unterrichtsmaterialien lassen sich klassenweise ausgeben und zum Schuljahresende sauber rueckfuehren.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Lizenzfristen im Blick</h3>
|
||||
<p>Auslaufende Lizenzen werden rechtzeitig sichtbar, damit keine Unterrichtsausfälle durch fehlende Software entstehen.</p>
|
||||
<h3>Scanner-gestuetzte Ausleihe</h3>
|
||||
<p>Barcode- oder QR-Workflows beschleunigen Ausgabe und Ruecknahme deutlich, auch bei grossen Bestandsmengen.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Verständliche Prozesse</h3>
|
||||
<p>Statt technischer Fachsprache zeigt die Plattform klare Schritte und eindeutige Statusmeldungen für jede Aufgabe.</p>
|
||||
<h3>IT- und Medienbestand kombiniert</h3>
|
||||
<p>Tablets, Laptops, Webcams und Print-Medien werden gemeinsam verwaltet statt in voneinander getrennten Listen.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="alltag" style="animation-delay: 180ms;">
|
||||
<h2>Typische Situationen aus dem Schulalltag</h2>
|
||||
<p>Die folgenden Beispiele zeigen, wie die Plattform bei wiederkehrenden Verwaltungsaufgaben hilft.</p>
|
||||
<h2>Typische Lehrmittel-Szenarien</h2>
|
||||
<p>Die folgenden Beispiele orientieren sich an realen Schulablaeufen mit Fokus auf Tempo und Nachvollziehbarkeit.</p>
|
||||
<div class="routine-list">
|
||||
<div class="routine">
|
||||
<div>
|
||||
<b>Neues Gerät kommt an</b>
|
||||
Seriennummer, Raum und Ansprechperson werden in wenigen Schritten eingetragen und sind sofort nachvollziehbar.
|
||||
<b>Neue Klassensaetze treffen ein</b>
|
||||
Lehrmittel werden etikettiert, Klassen zugeordnet und mit Ausgabehistorie gespeichert.
|
||||
</div>
|
||||
<span>Inventar</span>
|
||||
<span>Lehrmittel</span>
|
||||
</div>
|
||||
<div class="routine">
|
||||
<div>
|
||||
<b>Rückfrage zur Gerätehistorie</b>
|
||||
Status zu Bestellung, Nutzung oder Defekt ist direkt sichtbar, ohne mehrere Listen durchsuchen zu müssen.
|
||||
<b>Rueckfrage zu ausgeliehenen Medien</b>
|
||||
Verfuegbarkeit, Entleiher und Rueckgabedatum sind sofort sichtbar, ohne manuelle Listenpflege.
|
||||
</div>
|
||||
<span>Transparenz</span>
|
||||
<span>Ausleihe</span>
|
||||
</div>
|
||||
<div class="routine">
|
||||
<div>
|
||||
<b>Supportfall im Unterricht</b>
|
||||
Tickets werden dokumentiert und priorisiert. Das Team sieht jederzeit, wer bereits daran arbeitet.
|
||||
<b>Defekt bei einem Endgeraet</b>
|
||||
Zustand, Schaden und Verantwortlichkeiten werden dokumentiert, inklusive klarer Eskalationswege.
|
||||
</div>
|
||||
<span>Support</span>
|
||||
<span>Sicherheit</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="sicherheit" style="animation-delay: 220ms;">
|
||||
<h2>Allgemeine Sicherheit des Programms</h2>
|
||||
<p>Das Sicherheitskonzept verbindet technische Schutzmaßnahmen mit klaren Arbeitsabläufen, damit Daten im laufenden Betrieb zuverlässig geschützt bleiben.</p>
|
||||
<h2>Erweiterte Sicherheit fuer den Schulbetrieb</h2>
|
||||
<p>Die Plattform setzt auf Datenschutz und Betriebssicherheit, damit personenbezogene Daten und Inventardaten langfristig geschuetzt bleiben.</p>
|
||||
<div class="section-grid">
|
||||
<article class="card">
|
||||
<h3>Rollenbasierte Zugriffe</h3>
|
||||
<p>Nutzerkonten sehen nur Funktionen, die für die jeweilige Aufgabe benötigt werden. Das reduziert Fehlbedienungen und ungewollte Änderungen.</p>
|
||||
<h3>Rollen- und Bereichstrennung</h3>
|
||||
<p>Bibliothek, Verwaltung und IT erhalten abgestufte Rechte, sodass jede Rolle nur ihre benoetigten Funktionen sieht.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Dokumentierte Änderungen</h3>
|
||||
<p>Wichtige Aktionen werden nachvollziehbar protokolliert. Dadurch lassen sich Abläufe prüfen und Sicherheitsereignisse schneller einordnen.</p>
|
||||
<h3>Protokollierung und Nachweis</h3>
|
||||
<p>Ausgaben, Ruecknahmen und kritische Admin-Aktionen werden dokumentiert und sind jederzeit auditierbar.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Geschützte Übertragung</h3>
|
||||
<p>Verbindungen laufen über sichere Protokolle, ergänzt durch Sicherheits-Header und strikte Session-Regeln für den täglichen Webbetrieb.</p>
|
||||
<h3>Gesicherte Web-Session</h3>
|
||||
<p>Sicherheits-Header, HttpOnly-Cookies und klare Session-Richtlinien schuetzen den laufenden Betrieb vor typischen Webangriffen.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="logo-wall" id="logos">
|
||||
<h2>Einblicke und Logo-Varianten</h2>
|
||||
<p>Die folgenden Grafiken sind direkt aus dem statischen Bereich Ihrer Website eingebunden.</p>
|
||||
<div class="logo-grid">
|
||||
<figure class="logo-item">
|
||||
<img src="{{ url_for('static', filename='images/logo-1.jpeg') }}" alt="Invario Logo Variante 1">
|
||||
</figure>
|
||||
<figure class="logo-item">
|
||||
<img src="{{ url_for('static', filename='images/logo-2.jpeg') }}" alt="Invario Logo Variante 2">
|
||||
</figure>
|
||||
<figure class="logo-item">
|
||||
<img src="{{ url_for('static', filename='images/logo-3.jpeg') }}" alt="Invario Logo Variante 3">
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="ablauf" style="animation-delay: 260ms;">
|
||||
<h2>So verläuft die Zusammenarbeit</h2>
|
||||
<p>Ein klarer Prozess sorgt für Sicherheit, besonders bei begrenzten Zeitfenstern im Schulbetrieb.</p>
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
<label for="username">Benutzername</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">E-Mail</label>
|
||||
<input id="email" name="email" type="email" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||
|
||||
+12
-2
@@ -63,7 +63,7 @@ def check_nm_pwd(username, password):
|
||||
client.close()
|
||||
|
||||
|
||||
def add_user(username, password, name, last_name):
|
||||
def add_user(username, password, name, last_name, email=""):
|
||||
"""
|
||||
Add a new user to the database.
|
||||
|
||||
@@ -80,7 +80,17 @@ def add_user(username, password, name, last_name):
|
||||
client = None
|
||||
try:
|
||||
client, users = _get_users_collection()
|
||||
users.insert_one({'Username': username, 'Password': hashing(password), 'Admin': False, 'active_ausleihung': None, 'name': name, 'last_name': last_name})
|
||||
users.insert_one(
|
||||
{
|
||||
'Username': username,
|
||||
'Password': hashing(password),
|
||||
'Admin': False,
|
||||
'active_ausleihung': None,
|
||||
'name': name,
|
||||
'last_name': last_name,
|
||||
'email': (email or '').strip().lower(),
|
||||
}
|
||||
)
|
||||
return True
|
||||
finally:
|
||||
if client:
|
||||
|
||||
Reference in New Issue
Block a user