diff --git a/Website/launch.sh b/Website/launch.sh index 936f8fb..3035544 100755 --- a/Website/launch.sh +++ b/Website/launch.sh @@ -349,6 +349,13 @@ 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 ;; diff --git a/Website/main.py b/Website/main.py index 60a1a0a..efcc018 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 @@ -158,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) @@ -2308,12 +2313,17 @@ def background_booking_provisioning( "admin_username": "admin", "admin_password": admin_password, "created_at": _utc_now_iso(), - "is_trial": is_trial + "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: @@ -2338,6 +2348,14 @@ def background_booking_provisioning( 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}") @@ -2436,6 +2454,156 @@ 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(): @@ -2451,6 +2619,13 @@ def booking_payment(): 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" @@ -3025,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 @@ -3047,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(): @@ -3686,5 +3898,8 @@ def test_email(): 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""" +
{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.
+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!