changes to the sceduler for the test version with the right processing
This commit is contained in:
@@ -349,6 +349,13 @@ watcher_loop() {
|
|||||||
echo "[ERROR] Fehler beim Neustart von: $SLUG" >&2
|
echo "[ERROR] Fehler beim Neustart von: $SLUG" >&2
|
||||||
fi
|
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")
|
"trial")
|
||||||
echo "[ERROR] Veralteter trial-Trigger für $SLUG: Testversionen müssen über add erstellt werden." >&2
|
echo "[ERROR] Veralteter trial-Trigger für $SLUG: Testversionen müssen über add erstellt werden." >&2
|
||||||
;;
|
;;
|
||||||
|
|||||||
+217
-2
@@ -27,7 +27,7 @@ from bson.binary import Binary
|
|||||||
import user as user_store
|
import user as user_store
|
||||||
import buchungen
|
import buchungen
|
||||||
import server_steering as steering
|
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 secrets
|
||||||
import requests
|
import requests
|
||||||
from server_steering import Instance
|
from server_steering import Instance
|
||||||
@@ -158,6 +158,11 @@ def _env_int(name: str, default: int) -> int:
|
|||||||
return default
|
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_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_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)
|
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_username": "admin",
|
||||||
"admin_password": admin_password,
|
"admin_password": admin_password,
|
||||||
"created_at": _utc_now_iso(),
|
"created_at": _utc_now_iso(),
|
||||||
"is_trial": is_trial
|
"is_trial": is_trial,
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_trial:
|
if is_trial:
|
||||||
instance_doc["trial_days"] = trial_days
|
instance_doc["trial_days"] = trial_days
|
||||||
instance_doc["expires_at"] = expires_at
|
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")
|
inst_client, inst_col = _get_collection("instances")
|
||||||
try:
|
try:
|
||||||
@@ -2338,6 +2348,14 @@ def background_booking_provisioning(
|
|||||||
software_name=f"{software_name} (Testversion)" if is_trial else software_name,
|
software_name=f"{software_name} (Testversion)" if is_trial else software_name,
|
||||||
user_id=username
|
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:
|
except Exception as mail_err:
|
||||||
print(f"[WARNING] E-Mail-Versand fehlgeschlagen: {mail_err}")
|
print(f"[WARNING] E-Mail-Versand fehlgeschlagen: {mail_err}")
|
||||||
@@ -2436,6 +2454,156 @@ def background_tenant_removal(
|
|||||||
inst_client.close()
|
inst_client.close()
|
||||||
req_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'])
|
@app.route('/booking/payment', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def booking_payment():
|
def booking_payment():
|
||||||
@@ -2451,6 +2619,13 @@ def booking_payment():
|
|||||||
is_trial = booking_flow in {"trial", "testversion"} or requested_trial or package_requests_trial
|
is_trial = booking_flow in {"trial", "testversion"} or requested_trial or package_requests_trial
|
||||||
trial_days = int(request.values.get("trial_days") or 14)
|
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":
|
if booking_flow not in BOOKING_FLOW_MAP and booking_flow != "trial":
|
||||||
booking_flow = "payment"
|
booking_flow = "payment"
|
||||||
|
|
||||||
@@ -3025,6 +3200,10 @@ def my_instance_management():
|
|||||||
"nginx_status": _sanitize_text(instance_doc.get("nginx_status") or "unbekannt", 80),
|
"nginx_status": _sanitize_text(instance_doc.get("nginx_status") or "unbekannt", 80),
|
||||||
"last_message": _sanitize_text(instance_doc.get("last_message") or "", 500),
|
"last_message": _sanitize_text(instance_doc.get("last_message") or "", 500),
|
||||||
"updated_at": instance_doc.get("updated_at") or "",
|
"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:
|
else:
|
||||||
# Wenn noch keine Instanz da ist, prüfen wir ob eine gerade erstellt wird
|
# 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'])
|
@app.route('/my/tutorials', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def my_tutorials():
|
def my_tutorials():
|
||||||
@@ -3686,5 +3898,8 @@ def test_email():
|
|||||||
def main():
|
def main():
|
||||||
app.run(host="0.0.0.0", port=4999, debug=False)
|
app.run(host="0.0.0.0", port=4999, debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
_start_trial_maintenance_worker()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -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)
|
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"""
|
||||||
|
<div style="font-family: Arial, Helvetica, sans-serif; color: #333333;">
|
||||||
|
<h2 style="color: #2c3e50;">{subject}</h2>
|
||||||
|
<p>{message}</p>
|
||||||
|
<p><strong>Schule:</strong> {school_name}<br>
|
||||||
|
<strong>Adresse:</strong> <a href="https://{domain}">https://{domain}</a><br>
|
||||||
|
<strong>Ablaufdatum:</strong> {expires_at or "-"}</p>
|
||||||
|
<p><a href="{upgrade_url}">Jetzt auf eine reguläre Lizenz upgraden</a></p>
|
||||||
|
<p style="color: #64748b;">Bitte sichern Sie Ihre Daten vor Ablauf bzw. Löschung der Testversion.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
return send(recipient, subject, text_body=text_body, html_body=html_body)
|
||||||
|
|
||||||
def send_password_reset_token(email: str, token: str) -> bool:
|
def send_password_reset_token(email: str, token: str) -> bool:
|
||||||
"""Sends a professionally styled password reset token to the user."""
|
"""Sends a professionally styled password reset token to the user."""
|
||||||
subject = "Aktion erforderlich: Ihr Passwort-Zurücksetzungs-Code für Invario"
|
subject = "Aktion erforderlich: Ihr Passwort-Zurücksetzungs-Code für Invario"
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
<p><strong>Subdomain:</strong> {{ instance.subdomain }}</p>
|
<p><strong>Subdomain:</strong> {{ instance.subdomain }}</p>
|
||||||
<p><strong>Status:</strong> {{ instance.status }}</p>
|
<p><strong>Status:</strong> {{ instance.status }}</p>
|
||||||
<p><strong>Zugang:</strong> <a href="https://{{ instance.domain }}" target="_blank" rel="noopener noreferrer">https://{{ instance.domain }}</a></p>
|
<p><strong>Zugang:</strong> <a href="https://{{ instance.domain }}" target="_blank" rel="noopener noreferrer">https://{{ instance.domain }}</a></p>
|
||||||
|
{% if instance.is_trial %}
|
||||||
|
<p><strong>Testversion:</strong> aktiv bis {{ instance.expires_at }}</p>
|
||||||
|
{% if instance.trial_state == "active" and not instance.upgrade_requested %}
|
||||||
|
<form method="post" action="{{ url_for('request_trial_upgrade') }}" style="margin-top: 1rem;">
|
||||||
|
<button type="submit">Auf reguläre Lizenz upgraden</button>
|
||||||
|
</form>
|
||||||
|
{% elif instance.upgrade_requested %}
|
||||||
|
<p>Ihre Upgrade-Anfrage wird vom Team bearbeitet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% elif pending_request %}
|
{% elif pending_request %}
|
||||||
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 1rem; margin-top: 1rem;">
|
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 1rem; margin-top: 1rem;">
|
||||||
<p><strong>Ihre Instanz wird gerade eingerichtet!</strong></p>
|
<p><strong>Ihre Instanz wird gerade eingerichtet!</strong></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user