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""" +
+

{subject}

+

{message}

+

Schule: {school_name}
+ Adresse: https://{domain}
+ Ablaufdatum: {expires_at or "-"}

+

Jetzt auf eine reguläre Lizenz upgraden

+

Bitte sichern Sie Ihre Daten vor Ablauf bzw. Löschung der Testversion.

+
+ """ + 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 @@
+ +
+
+
+ Kostenlos +

Testversion

+
+
+ 0 € -> 14 Tage +
+
+

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 %} +
+
@@ -232,35 +260,6 @@
- - -
@@ -336,8 +335,8 @@
- - + +
@@ -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 @@
+
1. Angaben zur Schule & Ansprechpartner
@@ -524,17 +525,16 @@ let backendData = null; // Speichert das finale Backend-Resultat let isFailed = false; - // Echte Log-Meldungen, die exakt alle 10 Sekunden durchgeschaltet werden const statusSteps = [ - { atMs: 0, msg: "[INFO] Verarbeite Trigger: Starte Provisionierung für " + slug + "..." }, - { atMs: 10000, msg: "[INFO] Prüfe freie Ports... Verwende Port 10004 für Tenant " + slug }, - { atMs: 20000, msg: "Requesting a certificate for " + slug + ".invario-software.de..." }, - { atMs: 30000, msg: "Successfully deployed certificate to Nginx. Restarting app container..." }, - { atMs: 40000, msg: "Container inventarsystem-app-1 Recreated. Waiting for MongoDB/Redis..." }, - { atMs: 50000, msg: "Initializing database for " + slug + "... Default admin created." }, - { atMs: 60000, msg: "Module configurations updated successfully (library=on)..." }, - { atMs: 70000, msg: "Rebuilding and/or restarting app container using docker-compose..." }, - { atMs: 80000, msg: "[INFO] Bereinige verwaiste App-Container. Cleaning up old temporary files..." } + { atMs: 0, msg: "[INFO] Processing trigger: Starting deployment for " + slug + "..." }, + { atMs: 10000, msg: "[INFO] Checking available resources and allocating network ports..." }, + { atMs: 20000, msg: "[INFO] Requesting security certificate for " + slug + "..." }, + { atMs: 30000, msg: "[INFO] Deploying certificate to proxy layer. Restarting service..." }, + { atMs: 40000, msg: "[INFO] Service containers recreated. Waiting for backend dependencies..." }, + { atMs: 50000, msg: "[INFO] Initializing storage for " + slug + "... Default admin created." }, + { atMs: 60000, msg: "[INFO] Configurations updated successfully..." }, + { atMs: 70000, msg: "[INFO] Rebuilding and restarting application stack..." }, + { atMs: 80000, msg: "[INFO] Cleaning up temporary files and orphan processes..." } ]; // 1. Hintergrund-Polling: Fragt den echten Status ab, OHNE die UI abzubrechen