diff --git a/Website/launch.sh b/Website/launch.sh
index cd710aa..3035544 100755
--- a/Website/launch.sh
+++ b/Website/launch.sh
@@ -179,6 +179,8 @@ build_website_image() {
# ============================================================================
TARGET_SYSTEM_DIR="/opt/Inventarsystem"
TARGET_SCRIPT="$TARGET_SYSTEM_DIR/manage-tenant.sh"
+TRIAL_EXPIRATION_DIR="$TARGET_SYSTEM_DIR/trial-expirations"
+TRIAL_MODULES_OFF="library=off inventory=off student_cards=off terminplan=off"
get_free_port() {
local requested_port="${1:-10002}"
@@ -201,15 +203,62 @@ get_free_port() {
echo "$check_port"
}
+register_trial_expiration() {
+ local slug="$1"
+ local expires_at="$2"
+ local expires_epoch
+
+ expires_epoch=$(date -d "$expires_at" +%s 2>/dev/null || true)
+ if [ -z "$expires_epoch" ]; then
+ echo "[ERROR] Ungültiges Trial-Ablaufdatum für $slug: $expires_at" >&2
+ return 1
+ fi
+
+ mkdir -p "$TRIAL_EXPIRATION_DIR"
+ printf '%s\n' "$expires_epoch" > "$TRIAL_EXPIRATION_DIR/$slug.expiry"
+}
+
+deactivate_expired_trials() {
+ local now_epoch
+ now_epoch=$(date +%s)
+
+ for expiry_file in "$TRIAL_EXPIRATION_DIR"/*.expiry; do
+ [ -f "$expiry_file" ] || continue
+
+ local slug expires_epoch
+ slug="${expiry_file##*/}"
+ slug="${slug%.expiry}"
+ expires_epoch=$(cat "$expiry_file" 2>/dev/null || true)
+ if ! [[ "$expires_epoch" =~ ^[0-9]+$ ]]; then
+ echo "[ERROR] Ungültige Ablaufdatei: $expiry_file" >&2
+ continue
+ fi
+ if [ "$expires_epoch" -gt "$now_epoch" ]; then
+ continue
+ fi
+
+ echo "[INFO] Deaktiviere abgelaufene Testversion: $slug"
+ if (cd "$TARGET_SYSTEM_DIR" && "$TARGET_SCRIPT" module "$slug" $TRIAL_MODULES_OFF); then
+ rm -f "$expiry_file"
+ echo "[SUCCESS] Testversion deaktiviert: $slug"
+ else
+ echo "[ERROR] Testversion konnte nicht deaktiviert werden: $slug" >&2
+ fi
+ done
+}
+
watcher_loop() {
local trigger_dir="$1"
echo "[INFO] Tenant Watcher gestartet. Lausche in $trigger_dir..."
+ mkdir -p "$TRIAL_EXPIRATION_DIR"
while true; do
+ deactivate_expired_trials
for file in "$trigger_dir"/*.json; do
[ -f "$file" ] || continue
echo "[INFO] Verarbeite Trigger: $file"
+ EXPIRES_AT=""
if command -v jq >/dev/null 2>&1; then
ACTION=$(jq -r '.action // "add"' "$file" 2>/dev/null || echo "add")
@@ -217,6 +266,7 @@ watcher_loop() {
PORT=$(jq -r '.port // empty' "$file" 2>/dev/null || true)
PASS=$(jq -r '.password // empty' "$file" 2>/dev/null || true)
MODULES=$(jq -r '.module_config // empty' "$file" 2>/dev/null || true)
+ EXPIRES_AT=$(jq -r '.expires_at // empty' "$file" 2>/dev/null || true)
else
ACTION=$(grep -o '"action": *"[^"]*"' "$file" | cut -d'"' -f4 || echo "add")
[ -z "$ACTION" ] && ACTION="add"
@@ -224,6 +274,7 @@ watcher_loop() {
PORT=$(grep -o '"port": *[0-9]*' "$file" | cut -d':' -f2 | tr -d ' ' || true)
PASS=$(grep -o '"password": *"[^"]*"' "$file" | cut -d'"' -f4 || true)
MODULES=$(grep -o '"module_config": *"[^"]*"' "$file" | cut -d'"' -f4 || true)
+ EXPIRES_AT=$(grep -o '"expires_at": *"[^"]*"' "$file" | cut -d'"' -f4 || true)
fi
if [ -z "$SLUG" ]; then
@@ -262,6 +313,10 @@ watcher_loop() {
sleep 8
fi
+ if [ -n "${EXPIRES_AT:-}" ]; then
+ register_trial_expiration "$SLUG" "$EXPIRES_AT" || true
+ fi
+
# Um sicherzugehen, löschen wir verwaiste Container, bevor das Skript greift
echo "[INFO] Bereinige verwaiste App-Container..."
sudo docker rm -f inventarsystem-app-1 2>/dev/null || true
@@ -294,6 +349,16 @@ watcher_loop() {
echo "[ERROR] Fehler beim Neustart von: $SLUG" >&2
fi
;;
+ "deactivate")
+ if (cd "$TARGET_SYSTEM_DIR" && "$TARGET_SCRIPT" module "$SLUG" $TRIAL_MODULES_OFF); then
+ echo "[SUCCESS] Testversion deaktiviert: $SLUG"
+ else
+ echo "[ERROR] Testversion konnte nicht deaktiviert werden: $SLUG" >&2
+ fi
+ ;;
+ "trial")
+ echo "[ERROR] Veralteter trial-Trigger für $SLUG: Testversionen müssen über add erstellt werden." >&2
+ ;;
*)
echo "[ERROR] Unbekannte Aktion ($ACTION) in $file" >&2
;;
diff --git a/Website/main.py b/Website/main.py
index d7399f5..a5da460 100644
--- a/Website/main.py
+++ b/Website/main.py
@@ -27,7 +27,7 @@ from bson.binary import Binary
import user as user_store
import buchungen
import server_steering as steering
-from modules.emailservice.email import send_register_token, send_accreditation_email
+from modules.emailservice.email import send_register_token, send_accreditation_email, send_trial_notification
import secrets
import requests
from server_steering import Instance
@@ -52,6 +52,7 @@ BOOKING_PACKAGE_MAP = {
"terminverwaltung": "Terminverwaltung",
"emailversand": "E-Mail Versand",
"schultraeger": "Schulträger Angebot",
+ "testversion": "Testversion (kostenlos)",
}
BOOKING_FLOW_MAP = {
@@ -79,6 +80,7 @@ BOOKING_PRICE_MAP = {
"terminverwaltung": 120.0,
"emailversand": 70.0,
"schultraeger": 0.0,
+ "testversion": 0.0,
}
BOOKING_ADMIN_EMAIL_FALLBACK = os.environ.get("BOOKING_ADMIN_EMAIL", "").strip()
@@ -156,6 +158,11 @@ def _env_int(name: str, default: int) -> int:
return default
+TRIAL_REMINDER_DAYS = max(_env_int("TRIAL_REMINDER_DAYS", 3), 1)
+TRIAL_DELETE_GRACE_DAYS = max(_env_int("TRIAL_DELETE_GRACE_DAYS", 30), 1)
+TRIAL_MAINTENANCE_INTERVAL = max(_env_int("TRIAL_MAINTENANCE_INTERVAL", 60), 10)
+
+
MONGO_MAX_POOL_SIZE = max(_env_int("MONGO_MAX_POOL_SIZE", 12), 1)
MONGO_MIN_POOL_SIZE = max(_env_int("MONGO_MIN_POOL_SIZE", 0), 0)
MONGO_MAX_IDLE_MS = max(_env_int("MONGO_MAX_IDLE_MS", 60000), 1000)
@@ -2225,16 +2232,31 @@ def background_booking_provisioning(
username,
address,
price,
- software_name="Invario Inventarsystem"
+ software_name="Invario Inventarsystem",
+ is_trial=False,
+ trial_days=14,
):
- """Hintergrund-Worker, der die Tenant-Bereitstellung über das Host-Watcher-Triggersystem auslöst."""
+ """
+ Hintergrund-Worker, der die Tenant-Bereitstellung über das Host-Watcher-Triggersystem auslöst.
+ Unterstützt reguläre Buchungen sowie Testversionen mit zeitlich begrenzter Modulfreischaltung.
+ """
with app_instance.app_context():
req_client, req_col = _get_collection("instance_requests")
try:
- # 1. Status auf "in_progress" setzen
- req_col.update_one({"_id": ObjectId(prov_id)}, {"$set": {"provision_status": "in_progress"}})
+ # 1. Präfix für Testversionen verarbeiten
+ effective_subdomain = subdomain
+
+ # 2. Status auf "in_progress" setzen
+ req_col.update_one(
+ {"_id": ObjectId(prov_id)},
+ {"$set": {
+ "provision_status": "in_progress",
+ "is_trial": is_trial,
+ "effective_subdomain": effective_subdomain
+ }}
+ )
- # 2. Nächsten Port und Admin-Passwort ermitteln
+ # 3. Nächsten Port und Admin-Passwort ermitteln
inst_client_check, inst_col_check = _get_collection("instances")
try:
existing_instances = list(inst_col_check.find({}, {"https_port": 1, "port": 1}))
@@ -2250,49 +2272,66 @@ def background_booking_provisioning(
admin_password = generate_secure_password()
# Generiere den korrekten Modul-String
- module_config_str = build_module_config_string(package_list)
+ # Trial tenants use the regular instance path, with every module enabled.
+ module_config_str = " ".join(f"{module}=on" for module in ALL_MODULE_KEYS) if is_trial else build_module_config_string(package_list)
- # 3. Trigger-Datei für den Host-Watcher schreiben
+ # 4. Ablaufdatum berechnen (bei Testversion)
+ now = datetime.now(timezone.utc)
+ expires_at = (now + timedelta(days=trial_days)).isoformat() if is_trial else None
+
+ # 5. Trigger-Datei für den Host-Watcher schreiben
os.makedirs(TRIGGER_DIR, exist_ok=True)
- trigger_file = os.path.join(TRIGGER_DIR, f"{subdomain}.json")
+ trigger_file = os.path.join(TRIGGER_DIR, f"{effective_subdomain}.json")
trigger_payload = {
"action": "add",
"request_id": str(prov_id),
- "slug": subdomain,
+ "slug": effective_subdomain,
"port": next_port,
"password": admin_password,
- "module_config": module_config_str
+ "module_config": module_config_str,
}
+ if is_trial:
+ trigger_payload["expires_at"] = expires_at
with open(trigger_file, "w") as f:
json.dump(trigger_payload, f)
print(f"[INFO] Trigger-Datei für Host-Watcher erstellt: {trigger_file} mit Payload: {trigger_payload}")
- # 4. Instanz-Eintrag in der Datenbank registrieren
- domain = f"{subdomain}.invario-software.de"
+ # 6. Instanz-Eintrag in der Datenbank registrieren
+ domain = f"{effective_subdomain}.invario-software.de"
instance_doc = {
"owner_id": user_id,
"owner_username": username,
"school_name": school_name,
- "subdomain": subdomain,
+ "subdomain": effective_subdomain,
"domain": domain,
"https_port": next_port,
"status": "ready",
"nginx_status": "active",
"admin_username": "admin",
"admin_password": admin_password,
- "created_at": _utc_now_iso()
+ "created_at": _utc_now_iso(),
+ "is_trial": is_trial,
}
-
+
+ if is_trial:
+ instance_doc["trial_days"] = trial_days
+ instance_doc["expires_at"] = expires_at
+ instance_doc["trial_state"] = "active"
+ instance_doc["trial_activated_at"] = _utc_now_iso()
+ instance_doc["trial_reminder_email_sent"] = False
+ instance_doc["trial_expired_email_sent"] = False
+ instance_doc["trial_deleted_email_sent"] = False
+
inst_client, inst_col = _get_collection("instances")
try:
created_inst_id = str(inst_col.insert_one(instance_doc).inserted_id)
finally:
inst_client.close()
- # 5. Bestätigungs-E-Mail inkl. Vertrag & Anhängen versenden
+ # 7. Bestätigungs-E-Mail versenden
try:
if user_email:
formatted_date = datetime.today().strftime('%d.%m.%Y')
@@ -2304,29 +2343,42 @@ def background_booking_provisioning(
password=admin_password,
school_name=school_name,
address=address,
- price=price,
+ price=price if not is_trial else "Kostenlose Testversion",
date=formatted_date,
- software_name=software_name,
+ software_name=f"{software_name} (Testversion)" if is_trial else software_name,
user_id=username
)
+ if is_trial:
+ send_trial_notification(
+ recipient=user_email,
+ event="started",
+ domain=domain,
+ school_name=school_name,
+ expires_at=expires_at or "",
+ )
except Exception as mail_err:
print(f"[WARNING] E-Mail-Versand fehlgeschlagen: {mail_err}")
- # 6. Status in instance_requests auf "ready" setzen
+ # 8. Status in instance_requests auf "ready" setzen
+ update_fields = {
+ "provision_status": "ready",
+ "provision_port": next_port,
+ "instance_id": created_inst_id,
+ "url": f"https://{domain}",
+ "username": "admin",
+ "password": admin_password,
+ "updated_at": _utc_now_iso(),
+ "is_trial": is_trial
+ }
+ if is_trial:
+ update_fields["expires_at"] = expires_at
+
req_col.update_one(
{"_id": ObjectId(prov_id)},
- {"$set": {
- "provision_status": "ready",
- "provision_port": next_port,
- "instance_id": created_inst_id,
- "url": f"https://{domain}",
- "username": "admin",
- "password": admin_password,
- "updated_at": _utc_now_iso()
- }}
+ {"$set": update_fields}
)
- print(f"[SUCCESS] Provisioning-Trigger erfolgreich gesendet für {subdomain}")
+ print(f"[SUCCESS] Provisioning-Trigger erfolgreich gesendet für {effective_subdomain}")
except Exception as e:
print(f"[ERROR] Async Provisioning fehlgeschlagen für {subdomain}:")
@@ -2402,13 +2454,179 @@ def background_tenant_removal(
inst_client.close()
req_client.close()
+
+def _trial_datetime(value) -> datetime | None:
+ if isinstance(value, datetime):
+ parsed = value
+ elif isinstance(value, str):
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ else:
+ return None
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
+
+
+def _queue_trial_action(action: str, instance: dict) -> None:
+ slug = _sanitize_text(instance.get("subdomain") or "", 63)
+ if not slug:
+ return
+ os.makedirs(TRIGGER_DIR, exist_ok=True)
+ suffix = "deactivate" if action == "deactivate" else "remove"
+ trigger_path = os.path.join(TRIGGER_DIR, f"trial_{suffix}_{slug}.json")
+ with open(trigger_path, "w", encoding="utf-8") as handle:
+ json.dump({"action": action, "slug": slug}, handle)
+
+
+def _trial_recipient(instance: dict) -> str:
+ owner = _find_user(instance.get("owner_username") or "")
+ return _sanitize_text((owner or {}).get("email") or "", 254)
+
+
+def _send_trial_event_once(instance: dict, event: str, field: str) -> None:
+ if instance.get(field) or not _trial_recipient(instance):
+ return
+ updated = _get_collection("instances")
+ client, col = updated
+ try:
+ result = col.update_one(
+ {"_id": instance.get("_id"), "is_trial": True, field: {"$ne": True}},
+ {"$set": {field: True, "updated_at": _utc_now_iso()}},
+ )
+ finally:
+ client.close()
+ if not result.modified_count:
+ return
+ expires_at = str(instance.get("expires_at") or "")
+ send_trial_notification(
+ recipient=_trial_recipient(instance),
+ event=event,
+ domain=_sanitize_text(instance.get("domain") or "", 190),
+ school_name=_sanitize_text(instance.get("school_name") or "Ihre Schule", 120),
+ expires_at=expires_at,
+ )
+
+
+def _run_trial_maintenance_once() -> None:
+ now = datetime.now(timezone.utc)
+ client, col = _get_collection("instances")
+ try:
+ trials = list(col.find({"is_trial": True, "trial_state": {"$nin": ["deleted", "upgraded"]}}))
+ finally:
+ client.close()
+
+ for instance in trials:
+ expires_at = _trial_datetime(instance.get("expires_at"))
+ if not expires_at:
+ continue
+
+ if now < expires_at:
+ reminder_at = expires_at - timedelta(days=TRIAL_REMINDER_DAYS)
+ if now >= reminder_at:
+ _send_trial_event_once(instance, "reminder", "trial_reminder_email_sent")
+ continue
+
+ if instance.get("trial_state") not in {"expired", "deletion_requested"}:
+ client, col = _get_collection("instances")
+ try:
+ changed = col.update_one(
+ {"_id": instance.get("_id"), "is_trial": True, "trial_state": {"$nin": ["expired", "deletion_requested", "upgraded", "deleted"]}},
+ {"$set": {"trial_state": "expired", "status": "trial_expired", "modules_deactivated_at": _utc_now_iso(), "updated_at": _utc_now_iso()}},
+ ).modified_count
+ finally:
+ client.close()
+ if changed:
+ _queue_trial_action("deactivate", instance)
+ instance["trial_state"] = "expired"
+
+ _send_trial_event_once(instance, "expired", "trial_expired_email_sent")
+
+ delete_at = expires_at + timedelta(days=TRIAL_DELETE_GRACE_DAYS)
+ if now < delete_at or instance.get("trial_state") == "deletion_requested":
+ continue
+
+ client, col = _get_collection("instances")
+ try:
+ changed = col.update_one(
+ {"_id": instance.get("_id"), "is_trial": True, "trial_state": "expired"},
+ {"$set": {"trial_state": "deletion_requested", "deletion_requested_at": _utc_now_iso(), "updated_at": _utc_now_iso()}},
+ ).modified_count
+ finally:
+ client.close()
+ if changed:
+ _queue_trial_action("remove", instance)
+ _send_trial_event_once(instance, "deleted", "trial_deleted_email_sent")
+ client, col = _get_collection("instances")
+ try:
+ col.delete_one({"_id": instance.get("_id"), "is_trial": True, "trial_state": "deletion_requested"})
+ finally:
+ client.close()
+
+ req_client, req_col = _get_collection("instance_requests")
+ try:
+ req_col.update_many(
+ {"instance_id": str(instance.get("_id")), "is_trial": True},
+ {"$set": {"trial_state": "deleted", "deleted_at": _utc_now_iso()}},
+ )
+ finally:
+ req_client.close()
+
+
+def _trial_maintenance_loop(app_instance) -> None:
+ while True:
+ try:
+ with app_instance.app_context():
+ _run_trial_maintenance_once()
+ except Exception:
+ traceback.print_exc()
+ time.sleep(TRIAL_MAINTENANCE_INTERVAL)
+
+
+def _start_trial_maintenance_worker() -> None:
+ if getattr(app, "trial_maintenance_started", False):
+ return
+ app.trial_maintenance_started = True
+ threading.Thread(target=_trial_maintenance_loop, args=(app,), daemon=True, name="trial-maintenance").start()
+
+
+def _user_has_used_trial(username: str) -> bool:
+ if not username:
+ return False
+ for collection_name in ("instances", "instance_requests"):
+ client, collection = _get_collection(collection_name)
+ try:
+ if collection.find_one({"username": username, "is_trial": True}, {"_id": 1}):
+ return True
+ if collection.find_one({"owner_username": username, "is_trial": True}, {"_id": 1}):
+ return True
+ finally:
+ client.close()
+ return False
+
@app.route('/booking/payment', methods=['GET', 'POST'])
@login_required
def booking_payment():
packages_str = _sanitize_text(request.values.get("packages") or request.values.get("package") or "", 200).lower()
booking_flow = _sanitize_text(request.values.get("flow") or "payment", 24).lower()
- if booking_flow not in BOOKING_FLOW_MAP:
+ requested_trial = request.values.get("testversion", "").strip().lower() in {"1", "on", "true", "yes"}
+ package_requests_trial = "testversion" in {
+ package.strip().lower()
+ for package in packages_str.split(",")
+ if package.strip()
+ }
+ is_trial = booking_flow in {"trial", "testversion"} or requested_trial or package_requests_trial
+ trial_days = int(request.values.get("trial_days") or 14)
+
+ if is_trial and _user_has_used_trial(session.get("username") or ""):
+ error_message = "Diese Testversion wurde für Ihr Konto bereits aktiviert. Bitte wenden Sie sich zum Upgrade an unser Team."
+ if request.is_json or "application/json" in request.headers.get("Accept", ""):
+ return jsonify({"success": False, "error": error_message}), 409
+ flash(error_message, "error")
+ return redirect(url_for("preise"))
+
+ if booking_flow not in BOOKING_FLOW_MAP and booking_flow != "trial":
booking_flow = "payment"
is_json_request = "application/json" in request.headers.get("Accept", "") or request.is_json
@@ -2438,7 +2656,7 @@ def booking_payment():
selected_package = ", ".join(selected_package_names)
package_raw = packages_str
- flow_config = BOOKING_FLOW_MAP[booking_flow]
+ flow_config = BOOKING_FLOW_MAP.get(booking_flow, BOOKING_FLOW_MAP.get("payment"))
form_data = {
"contact_person": _sanitize_text(request.values.get("contact_person") or "", 120),
@@ -2462,8 +2680,9 @@ def booking_payment():
review_data = {
"package": selected_package,
"package_key": package_raw,
- "package_price": total_price,
+ "package_price": total_price if not is_trial else 0.0,
"flow": booking_flow,
+ "is_trial": is_trial,
"title": flow_config["title"],
"intro": flow_config["intro"],
"confirm_label": flow_config["confirm_label"],
@@ -2516,7 +2735,6 @@ def booking_payment():
assigned_admin_email = (assigned_admin.get("email") if assigned_admin else "") or BOOKING_ADMIN_EMAIL_FALLBACK
try:
- # FLOW 1: Consultation / Chat
if booking_flow == "consultation":
client, col = _get_collection("chat_messages")
try:
@@ -2541,12 +2759,14 @@ def booking_payment():
flash(f"Gesprächstermin für {selected_package} wurde an das Team gesendet.", "success")
return redirect(url_for("user_chat"))
- # FLOW 2: Direktbuchung mit Provisioning via Host-Watcher
- if booking_flow == "payment":
- booking_number = f"BOOK-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
+ if booking_flow in ["payment", "testversion", "trial"]:
+ # CORRECTED: Syntax error in the f-string is fixed below (uses single quotes inside double quotes)
+ booking_number = f"{'TRIAL' if is_trial else 'BOOK'}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
raw_base = form_data.get("tenant_slug") or form_data.get("school_name") or session.get("username") or booking_number
base = _slugify_subdomain(raw_base).lower()[:51]
+
+ effective_subdomain = base
inst_check_client, inst_col_check = _get_collection("instances")
try:
@@ -2558,14 +2778,14 @@ def booking_payment():
finally:
inst_check_client.close()
- if base in existing_subdomains:
+ if effective_subdomain in existing_subdomains:
err_msg = "Die gewünschte Subdomain existiert bereits. Bitte wähle eine andere Subdomain."
if is_json_request:
return jsonify({"success": False, "error": err_msg}), 400
flash(err_msg, "error")
return render_template("schnell_buchung.html", **review_data)
- subdomain = base[:63]
+ subdomain = effective_subdomain[:63]
provision_payload = {
"user_id": session.get("user_id"),
@@ -2573,9 +2793,10 @@ def booking_payment():
"booking_number": booking_number,
"package": package_raw,
"package_label": selected_package,
- "amount_eur": total_price,
+ "amount_eur": total_price if not is_trial else 0.0,
"booking_flow": booking_flow,
"booking_data": form_data,
+ "is_trial": is_trial,
"assigned_admin_username": assigned_admin_username,
"assigned_admin_display_name": assigned_admin_display_name,
"assigned_admin_email": assigned_admin_email,
@@ -2597,15 +2818,14 @@ def booking_payment():
username = session.get("username")
user_email = session.get("email") or form_data.get("contact_email")
address_process = f"{form_data['billing_street']}, {form_data['billing_zip']} {form_data['billing_city']}"
- price_str = f"{total_price:.2f}".replace('.', ',')
+ price_str = f"{total_price:.2f}".replace('.', ',') if not is_trial else "Kostenlose Testversion"
- # Startet den asynchronen Provisioning-Thread mit exakten Parametern
thread = threading.Thread(
target=background_booking_provisioning,
args=(
app_obj,
prov_id,
- subdomain,
+ base,
package_list,
user_id,
form_data["school_name"],
@@ -2614,10 +2834,14 @@ def booking_payment():
address_process,
price_str,
selected_package,
- )
+ ),
+ kwargs={
+ "is_trial": is_trial,
+ "trial_days": trial_days
+ }
)
thread.start()
- print(f"[INFO] Background Provisioning Trigger gestartet für Subdomain: {subdomain}")
+ print(f"[INFO] Background Provisioning Trigger gestartet für Subdomain: {subdomain} (Trial: {is_trial})")
if is_json_request:
return jsonify({
@@ -2672,7 +2896,6 @@ def instance_request_delete():
return redirect(url_for("my_invoices"))
-
@app.route('/admin/instances', methods=['GET'])
@admin_required
def admin_instances():
@@ -2977,6 +3200,10 @@ def my_instance_management():
"nginx_status": _sanitize_text(instance_doc.get("nginx_status") or "unbekannt", 80),
"last_message": _sanitize_text(instance_doc.get("last_message") or "", 500),
"updated_at": instance_doc.get("updated_at") or "",
+ "is_trial": bool(instance_doc.get("is_trial", False)),
+ "trial_state": _sanitize_text(instance_doc.get("trial_state") or "", 40),
+ "expires_at": instance_doc.get("expires_at") or "",
+ "upgrade_requested": bool(instance_doc.get("upgrade_requested", False)),
}
else:
# Wenn noch keine Instanz da ist, prüfen wir ob eine gerade erstellt wird
@@ -2999,6 +3226,39 @@ def my_instance_management():
)
+@app.route("/trial/upgrade", methods=["POST"])
+@login_required
+def request_trial_upgrade():
+ username = session.get("username") or ""
+ client, col = _get_collection("instances")
+ try:
+ instance = col.find_one({"owner_username": username, "is_trial": True})
+ if not instance or instance.get("trial_state") != "active":
+ flash("Für diese Instanz ist kein aktives Upgrade möglich.", "error")
+ return redirect(url_for("my_instance_management"))
+ col.update_one(
+ {"_id": instance.get("_id"), "trial_state": "active"},
+ {"$set": {"upgrade_requested": True, "upgrade_requested_at": _utc_now_iso(), "updated_at": _utc_now_iso()}},
+ )
+ finally:
+ client.close()
+
+ client, messages = _get_collection("chat_messages")
+ try:
+ messages.insert_one({
+ "username": username,
+ "sender": session.get("display_name") or username,
+ "sender_role": "user",
+ "message": "Ich möchte meine Testversion in eine reguläre Lizenz upgraden.",
+ "created_at": _utc_now_iso(),
+ "booking_flow": "trial_upgrade",
+ })
+ finally:
+ client.close()
+ flash("Ihre Upgrade-Anfrage wurde an das Team gesendet.", "success")
+ return redirect(url_for("user_chat"))
+
+
@app.route('/my/tutorials', methods=['GET'])
@login_required
def my_tutorials():
@@ -3610,12 +3870,13 @@ def admin_delete_all_instances():
# Leite den Admin danach zurück zu einer sicheren Seite
return redirect(url_for('my_instance_management'))
+"""
@app.route('/test_email')
def test_email():
- """
- Test endpoint to send a sample email.
- This is for development purposes only.
- """
+
+ #Test endpoint to send a sample email.
+ #This is for development purposes only.
+
try:
send_accreditation_email(
recipient="maximilian.gruendinger@invario-software.de",
@@ -3632,9 +3893,13 @@ def test_email():
return "Test email sent successfully."
except Exception as e:
return f"Failed to send test email: {str(e)}", 500
-
+"""
+
def main():
app.run(host="0.0.0.0", port=4999, debug=False)
+
+_start_trial_maintenance_worker()
+
if __name__ == "__main__":
main()
\ No newline at end of file
diff --git a/Website/modules/emailservice/email.py b/Website/modules/emailservice/email.py
index 0dc43ca..a0a47e4 100644
--- a/Website/modules/emailservice/email.py
+++ b/Website/modules/emailservice/email.py
@@ -181,6 +181,55 @@ def send_register_token(email: str, token: str) -> bool:
return send(email, subject, text_body=text_note, html_body=html_note)
+
+def send_trial_notification(
+ recipient: str,
+ event: str,
+ domain: str,
+ school_name: str,
+ expires_at: str = "",
+ upgrade_url: str = "https://invario-software.de/preise",
+) -> bool:
+ """Send a lifecycle notification for a trial tenant."""
+ messages = {
+ "started": (
+ "Ihre 14-tägige Invario-Testversion ist aktiv.",
+ "Ihre Testversion wurde aktiviert. Alle Module sind für 14 Tage freigeschaltet.",
+ ),
+ "reminder": (
+ "Ihre Invario-Testversion endet bald.",
+ "Ihre Testversion endet in Kürze. Sie können jetzt auf eine reguläre Lizenz upgraden.",
+ ),
+ "expired": (
+ "Ihre Invario-Testversion ist abgelaufen.",
+ "Die 14-tägige Testversion ist abgelaufen und die Module wurden deaktiviert.",
+ ),
+ "deleted": (
+ "Ihre Invario-Testdaten wurden gelöscht.",
+ "Die Testdaten wurden 30 Tage nach Ablauf der Testversion endgültig gelöscht.",
+ ),
+ }
+ subject, message = messages.get(event, ("Information zu Ihrer Invario-Testversion", "Es gibt eine Aktualisierung zu Ihrer Testversion."))
+ expiry_note = f"\nAblaufdatum: {expires_at}" if expires_at else ""
+ text_body = (
+ f"Guten Tag,\n\n{message}\n\n"
+ f"Schule: {school_name}\nAdresse: https://{domain}{expiry_note}\n\n"
+ f"Upgrade: {upgrade_url}\n\n"
+ "Bitte sichern Sie Ihre Daten vor Ablauf bzw. Löschung der Testversion."
+ )
+ html_body = f"""
+
+ """
+ return send(recipient, subject, text_body=text_body, html_body=html_body)
+
def send_password_reset_token(email: str, token: str) -> bool:
"""Sends a professionally styled password reset token to the user."""
subject = "Aktion erforderlich: Ihr Passwort-Zurücksetzungs-Code für Invario"
diff --git a/Website/static/logos/Logo ohne Background .xcf b/Website/static/logos/Logo ohne Background .xcf
deleted file mode 100644
index b50507f..0000000
Binary files a/Website/static/logos/Logo ohne Background .xcf and /dev/null differ
diff --git a/Website/templates/impressum.html b/Website/templates/impressum.html
index d9a110a..42f9045 100644
--- a/Website/templates/impressum.html
+++ b/Website/templates/impressum.html
@@ -30,7 +30,7 @@
Registergericht: Amtsgericht Traunstein
Registernummer: HRB 35441
- USt-ID: ####### Noch nicht vorhanden
+ USt-ID: ########################### Folgt noch
@@ -165,7 +165,7 @@
-
Zuletzt aktualisiert: 5. Mai 2026
+
Zuletzt aktualisiert: 14. September 2026
diff --git a/Website/templates/my_instance.html b/Website/templates/my_instance.html
index 9c5fba6..18bc31b 100644
--- a/Website/templates/my_instance.html
+++ b/Website/templates/my_instance.html
@@ -15,6 +15,16 @@
Subdomain: {{ instance.subdomain }}
Status: {{ instance.status }}
Zugang: https://{{ instance.domain }}
+ {% if instance.is_trial %}
+ Testversion: aktiv bis {{ instance.expires_at }}
+ {% if instance.trial_state == "active" and not instance.upgrade_requested %}
+
+ {% elif instance.upgrade_requested %}
+ Ihre Upgrade-Anfrage wird vom Team bearbeitet.
+ {% endif %}
+ {% endif %}
{% elif pending_request %}
Ihre Instanz wird gerade eingerichtet!
diff --git a/Website/templates/preise.html b/Website/templates/preise.html
index cc88769..de8f9cb 100644
--- a/Website/templates/preise.html
+++ b/Website/templates/preise.html
@@ -146,6 +146,34 @@
+
+
+
+ Testen Sie alle Module (Inventar, Bibliothek & Terminplaner) 14 Tage lang unverbindlich in Ihrer Schule.
+
+ - Voller Zugriff auf alle Basis-Module
+ - Keine automatische Verlängerung
+ - Vollständige Datenübernahme bei Kauf
+ - Endet automatisch nach 14 Tagen
+
+
+ {% if 'username' in session %}
+
+ {% else %}
+
Zum Testen einloggen
+ {% endif %}
+
+
-
-
-
@@ -383,7 +382,10 @@
if(item.price > 0) total += item.price;
if(item.price === 0) hasIndividualPrice = true;
- const priceText = item.price > 0 ? `${item.price} €` : "Auf Anfrage";
+ let priceText = `${item.price} €`;
+ if (item.price === 0) {
+ priceText = item.id === 'testversion' ? 'Kostenlos' : 'Auf Anfrage';
+ }
container.innerHTML += `
@@ -404,6 +406,28 @@
} else {
totalPriceEl.innerText = `${total} € / Jahr`;
}
+
+ // Dynamic Checkout Button Text & Behavior
+
+ const btnCheckout = document.getElementById('btnCheckout');
+ const btnConsultation = document.getElementById('btnConsultation');
+
+ const isTrial = cart.some(item => item.id === 'testversion');
+ const isCustom = cart.some(item => item.id === 'schultraeger');
+
+ if (isTrial) {
+ btnCheckout.innerText = "Kostenlos testen (14 Tage)";
+ btnCheckout.setAttribute('onclick', "goToCheckout('trial')");
+ btnConsultation.style.display = 'none'; // Hide consultation for trials
+ } else if (isCustom) {
+ btnCheckout.innerText = "Angebot anfordern";
+ btnCheckout.setAttribute('onclick', "goToCheckout('consultation')"); // Force consultation
+ btnConsultation.style.display = 'none';
+ } else {
+ btnCheckout.innerText = "Jetzt Zahlungspflichtig buchen";
+ btnCheckout.setAttribute('onclick', "goToCheckout('payment')");
+ btnConsultation.style.display = 'inline-block';
+ }
}
// Artikel zum Warenkorb hinzufügen
@@ -414,6 +438,12 @@
return;
}
+ const hasTestversion = cart.find(i => i.id === 'testversion');
+ if (hasTestversion || (id === 'testversion' && cart.length > 0)) {
+ alert('Die Testversion beinhaltet bereits alle Module und kann nicht mit anderen Paketen kombiniert werden. Bitte leeren Sie Ihren Warenkorb zuerst.');
+ return;
+ }
+
// Konflikte mit Startpaket abfangen
const hasStartpaket = cart.find(i => i.id === 'startpaket');
if (hasStartpaket && (id === 'inventarsystem' || id === 'buecherei')) {
diff --git a/Website/templates/schnell_buchung.html b/Website/templates/schnell_buchung.html
index 9ebdec3..6c243a2 100644
--- a/Website/templates/schnell_buchung.html
+++ b/Website/templates/schnell_buchung.html
@@ -304,6 +304,7 @@