Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
@@ -179,6 +179,8 @@ build_website_image() {
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
TARGET_SYSTEM_DIR="/opt/Inventarsystem"
|
TARGET_SYSTEM_DIR="/opt/Inventarsystem"
|
||||||
TARGET_SCRIPT="$TARGET_SYSTEM_DIR/manage-tenant.sh"
|
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() {
|
get_free_port() {
|
||||||
local requested_port="${1:-10002}"
|
local requested_port="${1:-10002}"
|
||||||
@@ -201,15 +203,62 @@ get_free_port() {
|
|||||||
echo "$check_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() {
|
watcher_loop() {
|
||||||
local trigger_dir="$1"
|
local trigger_dir="$1"
|
||||||
echo "[INFO] Tenant Watcher gestartet. Lausche in $trigger_dir..."
|
echo "[INFO] Tenant Watcher gestartet. Lausche in $trigger_dir..."
|
||||||
|
mkdir -p "$TRIAL_EXPIRATION_DIR"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
|
deactivate_expired_trials
|
||||||
for file in "$trigger_dir"/*.json; do
|
for file in "$trigger_dir"/*.json; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
|
|
||||||
echo "[INFO] Verarbeite Trigger: $file"
|
echo "[INFO] Verarbeite Trigger: $file"
|
||||||
|
EXPIRES_AT=""
|
||||||
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
if command -v jq >/dev/null 2>&1; then
|
||||||
ACTION=$(jq -r '.action // "add"' "$file" 2>/dev/null || echo "add")
|
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)
|
PORT=$(jq -r '.port // empty' "$file" 2>/dev/null || true)
|
||||||
PASS=$(jq -r '.password // 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)
|
MODULES=$(jq -r '.module_config // empty' "$file" 2>/dev/null || true)
|
||||||
|
EXPIRES_AT=$(jq -r '.expires_at // empty' "$file" 2>/dev/null || true)
|
||||||
else
|
else
|
||||||
ACTION=$(grep -o '"action": *"[^"]*"' "$file" | cut -d'"' -f4 || echo "add")
|
ACTION=$(grep -o '"action": *"[^"]*"' "$file" | cut -d'"' -f4 || echo "add")
|
||||||
[ -z "$ACTION" ] && ACTION="add"
|
[ -z "$ACTION" ] && ACTION="add"
|
||||||
@@ -224,6 +274,7 @@ watcher_loop() {
|
|||||||
PORT=$(grep -o '"port": *[0-9]*' "$file" | cut -d':' -f2 | tr -d ' ' || true)
|
PORT=$(grep -o '"port": *[0-9]*' "$file" | cut -d':' -f2 | tr -d ' ' || true)
|
||||||
PASS=$(grep -o '"password": *"[^"]*"' "$file" | cut -d'"' -f4 || true)
|
PASS=$(grep -o '"password": *"[^"]*"' "$file" | cut -d'"' -f4 || true)
|
||||||
MODULES=$(grep -o '"module_config": *"[^"]*"' "$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
|
fi
|
||||||
|
|
||||||
if [ -z "$SLUG" ]; then
|
if [ -z "$SLUG" ]; then
|
||||||
@@ -262,6 +313,10 @@ watcher_loop() {
|
|||||||
sleep 8
|
sleep 8
|
||||||
fi
|
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
|
# Um sicherzugehen, löschen wir verwaiste Container, bevor das Skript greift
|
||||||
echo "[INFO] Bereinige verwaiste App-Container..."
|
echo "[INFO] Bereinige verwaiste App-Container..."
|
||||||
sudo docker rm -f inventarsystem-app-1 2>/dev/null || true
|
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
|
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")
|
||||||
|
echo "[ERROR] Veralteter trial-Trigger für $SLUG: Testversionen müssen über add erstellt werden." >&2
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[ERROR] Unbekannte Aktion ($ACTION) in $file" >&2
|
echo "[ERROR] Unbekannte Aktion ($ACTION) in $file" >&2
|
||||||
;;
|
;;
|
||||||
|
|||||||
+310
-45
@@ -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
|
||||||
@@ -52,6 +52,7 @@ BOOKING_PACKAGE_MAP = {
|
|||||||
"terminverwaltung": "Terminverwaltung",
|
"terminverwaltung": "Terminverwaltung",
|
||||||
"emailversand": "E-Mail Versand",
|
"emailversand": "E-Mail Versand",
|
||||||
"schultraeger": "Schulträger Angebot",
|
"schultraeger": "Schulträger Angebot",
|
||||||
|
"testversion": "Testversion (kostenlos)",
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOKING_FLOW_MAP = {
|
BOOKING_FLOW_MAP = {
|
||||||
@@ -79,6 +80,7 @@ BOOKING_PRICE_MAP = {
|
|||||||
"terminverwaltung": 120.0,
|
"terminverwaltung": 120.0,
|
||||||
"emailversand": 70.0,
|
"emailversand": 70.0,
|
||||||
"schultraeger": 0.0,
|
"schultraeger": 0.0,
|
||||||
|
"testversion": 0.0,
|
||||||
}
|
}
|
||||||
BOOKING_ADMIN_EMAIL_FALLBACK = os.environ.get("BOOKING_ADMIN_EMAIL", "").strip()
|
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
|
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)
|
||||||
@@ -2225,16 +2232,31 @@ def background_booking_provisioning(
|
|||||||
username,
|
username,
|
||||||
address,
|
address,
|
||||||
price,
|
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():
|
with app_instance.app_context():
|
||||||
req_client, req_col = _get_collection("instance_requests")
|
req_client, req_col = _get_collection("instance_requests")
|
||||||
try:
|
try:
|
||||||
# 1. Status auf "in_progress" setzen
|
# 1. Präfix für Testversionen verarbeiten
|
||||||
req_col.update_one({"_id": ObjectId(prov_id)}, {"$set": {"provision_status": "in_progress"}})
|
effective_subdomain = subdomain
|
||||||
|
|
||||||
# 2. Nächsten Port und Admin-Passwort ermitteln
|
# 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
|
||||||
|
}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Nächsten Port und Admin-Passwort ermitteln
|
||||||
inst_client_check, inst_col_check = _get_collection("instances")
|
inst_client_check, inst_col_check = _get_collection("instances")
|
||||||
try:
|
try:
|
||||||
existing_instances = list(inst_col_check.find({}, {"https_port": 1, "port": 1}))
|
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()
|
admin_password = generate_secure_password()
|
||||||
|
|
||||||
# Generiere den korrekten Modul-String
|
# 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)
|
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 = {
|
trigger_payload = {
|
||||||
"action": "add",
|
"action": "add",
|
||||||
"request_id": str(prov_id),
|
"request_id": str(prov_id),
|
||||||
"slug": subdomain,
|
"slug": effective_subdomain,
|
||||||
"port": next_port,
|
"port": next_port,
|
||||||
"password": admin_password,
|
"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:
|
with open(trigger_file, "w") as f:
|
||||||
json.dump(trigger_payload, f)
|
json.dump(trigger_payload, f)
|
||||||
|
|
||||||
print(f"[INFO] Trigger-Datei für Host-Watcher erstellt: {trigger_file} mit Payload: {trigger_payload}")
|
print(f"[INFO] Trigger-Datei für Host-Watcher erstellt: {trigger_file} mit Payload: {trigger_payload}")
|
||||||
|
|
||||||
# 4. Instanz-Eintrag in der Datenbank registrieren
|
# 6. Instanz-Eintrag in der Datenbank registrieren
|
||||||
domain = f"{subdomain}.invario-software.de"
|
domain = f"{effective_subdomain}.invario-software.de"
|
||||||
instance_doc = {
|
instance_doc = {
|
||||||
"owner_id": user_id,
|
"owner_id": user_id,
|
||||||
"owner_username": username,
|
"owner_username": username,
|
||||||
"school_name": school_name,
|
"school_name": school_name,
|
||||||
"subdomain": subdomain,
|
"subdomain": effective_subdomain,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"https_port": next_port,
|
"https_port": next_port,
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"nginx_status": "active",
|
"nginx_status": "active",
|
||||||
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
inst_client, inst_col = _get_collection("instances")
|
||||||
try:
|
try:
|
||||||
created_inst_id = str(inst_col.insert_one(instance_doc).inserted_id)
|
created_inst_id = str(inst_col.insert_one(instance_doc).inserted_id)
|
||||||
finally:
|
finally:
|
||||||
inst_client.close()
|
inst_client.close()
|
||||||
|
|
||||||
# 5. Bestätigungs-E-Mail inkl. Vertrag & Anhängen versenden
|
# 7. Bestätigungs-E-Mail versenden
|
||||||
try:
|
try:
|
||||||
if user_email:
|
if user_email:
|
||||||
formatted_date = datetime.today().strftime('%d.%m.%Y')
|
formatted_date = datetime.today().strftime('%d.%m.%Y')
|
||||||
@@ -2304,29 +2343,42 @@ def background_booking_provisioning(
|
|||||||
password=admin_password,
|
password=admin_password,
|
||||||
school_name=school_name,
|
school_name=school_name,
|
||||||
address=address,
|
address=address,
|
||||||
price=price,
|
price=price if not is_trial else "Kostenlose Testversion",
|
||||||
date=formatted_date,
|
date=formatted_date,
|
||||||
software_name=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}")
|
||||||
|
|
||||||
# 6. Status in instance_requests auf "ready" setzen
|
# 8. Status in instance_requests auf "ready" setzen
|
||||||
req_col.update_one(
|
update_fields = {
|
||||||
{"_id": ObjectId(prov_id)},
|
|
||||||
{"$set": {
|
|
||||||
"provision_status": "ready",
|
"provision_status": "ready",
|
||||||
"provision_port": next_port,
|
"provision_port": next_port,
|
||||||
"instance_id": created_inst_id,
|
"instance_id": created_inst_id,
|
||||||
"url": f"https://{domain}",
|
"url": f"https://{domain}",
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": admin_password,
|
"password": admin_password,
|
||||||
"updated_at": _utc_now_iso()
|
"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": 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:
|
except Exception as e:
|
||||||
print(f"[ERROR] Async Provisioning fehlgeschlagen für {subdomain}:")
|
print(f"[ERROR] Async Provisioning fehlgeschlagen für {subdomain}:")
|
||||||
@@ -2402,13 +2454,179 @@ 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():
|
||||||
packages_str = _sanitize_text(request.values.get("packages") or request.values.get("package") or "", 200).lower()
|
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()
|
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"
|
booking_flow = "payment"
|
||||||
|
|
||||||
is_json_request = "application/json" in request.headers.get("Accept", "") or request.is_json
|
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)
|
selected_package = ", ".join(selected_package_names)
|
||||||
package_raw = packages_str
|
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 = {
|
form_data = {
|
||||||
"contact_person": _sanitize_text(request.values.get("contact_person") or "", 120),
|
"contact_person": _sanitize_text(request.values.get("contact_person") or "", 120),
|
||||||
@@ -2462,8 +2680,9 @@ def booking_payment():
|
|||||||
review_data = {
|
review_data = {
|
||||||
"package": selected_package,
|
"package": selected_package,
|
||||||
"package_key": package_raw,
|
"package_key": package_raw,
|
||||||
"package_price": total_price,
|
"package_price": total_price if not is_trial else 0.0,
|
||||||
"flow": booking_flow,
|
"flow": booking_flow,
|
||||||
|
"is_trial": is_trial,
|
||||||
"title": flow_config["title"],
|
"title": flow_config["title"],
|
||||||
"intro": flow_config["intro"],
|
"intro": flow_config["intro"],
|
||||||
"confirm_label": flow_config["confirm_label"],
|
"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
|
assigned_admin_email = (assigned_admin.get("email") if assigned_admin else "") or BOOKING_ADMIN_EMAIL_FALLBACK
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# FLOW 1: Consultation / Chat
|
|
||||||
if booking_flow == "consultation":
|
if booking_flow == "consultation":
|
||||||
client, col = _get_collection("chat_messages")
|
client, col = _get_collection("chat_messages")
|
||||||
try:
|
try:
|
||||||
@@ -2541,13 +2759,15 @@ def booking_payment():
|
|||||||
flash(f"Gesprächstermin für {selected_package} wurde an das Team gesendet.", "success")
|
flash(f"Gesprächstermin für {selected_package} wurde an das Team gesendet.", "success")
|
||||||
return redirect(url_for("user_chat"))
|
return redirect(url_for("user_chat"))
|
||||||
|
|
||||||
# FLOW 2: Direktbuchung mit Provisioning via Host-Watcher
|
if booking_flow in ["payment", "testversion", "trial"]:
|
||||||
if booking_flow == "payment":
|
# CORRECTED: Syntax error in the f-string is fixed below (uses single quotes inside double quotes)
|
||||||
booking_number = f"BOOK-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
|
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
|
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]
|
base = _slugify_subdomain(raw_base).lower()[:51]
|
||||||
|
|
||||||
|
effective_subdomain = base
|
||||||
|
|
||||||
inst_check_client, inst_col_check = _get_collection("instances")
|
inst_check_client, inst_col_check = _get_collection("instances")
|
||||||
try:
|
try:
|
||||||
existing_docs = list(inst_col_check.find({}, {"subdomain": 1}))
|
existing_docs = list(inst_col_check.find({}, {"subdomain": 1}))
|
||||||
@@ -2558,14 +2778,14 @@ def booking_payment():
|
|||||||
finally:
|
finally:
|
||||||
inst_check_client.close()
|
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."
|
err_msg = "Die gewünschte Subdomain existiert bereits. Bitte wähle eine andere Subdomain."
|
||||||
if is_json_request:
|
if is_json_request:
|
||||||
return jsonify({"success": False, "error": err_msg}), 400
|
return jsonify({"success": False, "error": err_msg}), 400
|
||||||
flash(err_msg, "error")
|
flash(err_msg, "error")
|
||||||
return render_template("schnell_buchung.html", **review_data)
|
return render_template("schnell_buchung.html", **review_data)
|
||||||
|
|
||||||
subdomain = base[:63]
|
subdomain = effective_subdomain[:63]
|
||||||
|
|
||||||
provision_payload = {
|
provision_payload = {
|
||||||
"user_id": session.get("user_id"),
|
"user_id": session.get("user_id"),
|
||||||
@@ -2573,9 +2793,10 @@ def booking_payment():
|
|||||||
"booking_number": booking_number,
|
"booking_number": booking_number,
|
||||||
"package": package_raw,
|
"package": package_raw,
|
||||||
"package_label": selected_package,
|
"package_label": selected_package,
|
||||||
"amount_eur": total_price,
|
"amount_eur": total_price if not is_trial else 0.0,
|
||||||
"booking_flow": booking_flow,
|
"booking_flow": booking_flow,
|
||||||
"booking_data": form_data,
|
"booking_data": form_data,
|
||||||
|
"is_trial": is_trial,
|
||||||
"assigned_admin_username": assigned_admin_username,
|
"assigned_admin_username": assigned_admin_username,
|
||||||
"assigned_admin_display_name": assigned_admin_display_name,
|
"assigned_admin_display_name": assigned_admin_display_name,
|
||||||
"assigned_admin_email": assigned_admin_email,
|
"assigned_admin_email": assigned_admin_email,
|
||||||
@@ -2597,15 +2818,14 @@ def booking_payment():
|
|||||||
username = session.get("username")
|
username = session.get("username")
|
||||||
user_email = session.get("email") or form_data.get("contact_email")
|
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']}"
|
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(
|
thread = threading.Thread(
|
||||||
target=background_booking_provisioning,
|
target=background_booking_provisioning,
|
||||||
args=(
|
args=(
|
||||||
app_obj,
|
app_obj,
|
||||||
prov_id,
|
prov_id,
|
||||||
subdomain,
|
base,
|
||||||
package_list,
|
package_list,
|
||||||
user_id,
|
user_id,
|
||||||
form_data["school_name"],
|
form_data["school_name"],
|
||||||
@@ -2614,10 +2834,14 @@ def booking_payment():
|
|||||||
address_process,
|
address_process,
|
||||||
price_str,
|
price_str,
|
||||||
selected_package,
|
selected_package,
|
||||||
)
|
),
|
||||||
|
kwargs={
|
||||||
|
"is_trial": is_trial,
|
||||||
|
"trial_days": trial_days
|
||||||
|
}
|
||||||
)
|
)
|
||||||
thread.start()
|
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:
|
if is_json_request:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -2672,7 +2896,6 @@ def instance_request_delete():
|
|||||||
return redirect(url_for("my_invoices"))
|
return redirect(url_for("my_invoices"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/instances', methods=['GET'])
|
@app.route('/admin/instances', methods=['GET'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_instances():
|
def admin_instances():
|
||||||
@@ -2977,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
|
||||||
@@ -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'])
|
@app.route('/my/tutorials', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def my_tutorials():
|
def my_tutorials():
|
||||||
@@ -3610,12 +3870,13 @@ def admin_delete_all_instances():
|
|||||||
# Leite den Admin danach zurück zu einer sicheren Seite
|
# Leite den Admin danach zurück zu einer sicheren Seite
|
||||||
return redirect(url_for('my_instance_management'))
|
return redirect(url_for('my_instance_management'))
|
||||||
|
|
||||||
|
"""
|
||||||
@app.route('/test_email')
|
@app.route('/test_email')
|
||||||
def test_email():
|
def test_email():
|
||||||
"""
|
|
||||||
Test endpoint to send a sample email.
|
#Test endpoint to send a sample email.
|
||||||
This is for development purposes only.
|
#This is for development purposes only.
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
send_accreditation_email(
|
send_accreditation_email(
|
||||||
recipient="maximilian.gruendinger@invario-software.de",
|
recipient="maximilian.gruendinger@invario-software.de",
|
||||||
@@ -3632,9 +3893,13 @@ def test_email():
|
|||||||
return "Test email sent successfully."
|
return "Test email sent successfully."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Failed to send test email: {str(e)}", 500
|
return f"Failed to send test email: {str(e)}", 500
|
||||||
|
"""
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
Binary file not shown.
@@ -30,7 +30,7 @@
|
|||||||
<p>
|
<p>
|
||||||
<strong>Registergericht:</strong> Amtsgericht Traunstein<br>
|
<strong>Registergericht:</strong> Amtsgericht Traunstein<br>
|
||||||
<strong>Registernummer:</strong> HRB 35441<br>
|
<strong>Registernummer:</strong> HRB 35441<br>
|
||||||
<strong>USt-ID:</strong> ####### Noch nicht vorhanden
|
<strong>USt-ID:</strong> ########################### Folgt noch
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="last-updated">
|
<div class="last-updated">
|
||||||
<p><strong>Zuletzt aktualisiert:</strong> 5. Mai 2026</p>
|
<p><strong>Zuletzt aktualisiert:</strong> 14. September 2026</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -146,6 +146,34 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="packages-grid" aria-label="Buchungspakete">
|
<section class="packages-grid" aria-label="Buchungspakete">
|
||||||
|
<!-- TESTVERSION -->
|
||||||
|
<article class="package-card">
|
||||||
|
<div class="package-card__header">
|
||||||
|
<div>
|
||||||
|
<span class="package-card__tag" style="background: #e8f5e9; color: #2e7d32;">Kostenlos</span>
|
||||||
|
<h2 class="package-card__name">Testversion</h2>
|
||||||
|
</div>
|
||||||
|
<div class="package-card__price">
|
||||||
|
<span>0 €</span><small> -> 14 Tage</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="package-card__description">Testen Sie alle Module (Inventar, Bibliothek & Terminplaner) 14 Tage lang unverbindlich in Ihrer Schule.</p>
|
||||||
|
<ul class="package-card__features">
|
||||||
|
<li>Voller Zugriff auf alle Basis-Module</li>
|
||||||
|
<li>Keine automatische Verlängerung</li>
|
||||||
|
<li>Vollständige Datenübernahme bei Kauf</li>
|
||||||
|
<li>Endet automatisch nach 14 Tagen</li>
|
||||||
|
</ul>
|
||||||
|
<div class="package-card__actions">
|
||||||
|
{% if 'username' in session %}
|
||||||
|
<button class="btn add-to-cart-btn" data-id="testversion" data-name="Testversion (14 Tage)" data-price="0" onclick="handleAddToCart(this)">
|
||||||
|
Jetzt kostenlos starten
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn secondary" href="{{ url_for('login') }}">Zum Testen einloggen</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<!-- INVENTARSYSTEM -->
|
<!-- INVENTARSYSTEM -->
|
||||||
<article class="package-card">
|
<article class="package-card">
|
||||||
<div class="package-card__header">
|
<div class="package-card__header">
|
||||||
@@ -232,35 +260,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- STARTPAKET (Kombi) -->
|
|
||||||
<!--<article class="package-card highlight">
|
|
||||||
<div class="package-card__header">
|
|
||||||
<div>
|
|
||||||
<span class="package-card__tag highlight-tag">Sparpaket</span>
|
|
||||||
<h2 class="package-card__name">Startpaket</h2>
|
|
||||||
</div>
|
|
||||||
<div class="package-card__price">
|
|
||||||
<span>490 €</span><small>/ Jahr</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="package-card__description">Enthält <strong>Inventarverwaltung</strong> und <strong>Bibliothekssystem</strong> als ideale Komplettlösung zum Vorzugspreis.</p>
|
|
||||||
<ul class="package-card__features">
|
|
||||||
<li>Beinhaltet alle Features des Inventarsystems</li>
|
|
||||||
<li>Beinhaltet alle Features des Bibliothekssystems</li>
|
|
||||||
<li>Perfekter Start für Ihre Digitalisierung</li>
|
|
||||||
<li><strong>30 € Ersparnis</strong> gegenüber Einzelkauf</li>
|
|
||||||
</ul>
|
|
||||||
<div class="package-card__actions">
|
|
||||||
{% if 'username' in session %}
|
|
||||||
<button class="btn add-to-cart-btn" data-id="startpaket" data-name="Startpaket (Inventar + Bibliothek)" data-price="490" onclick="handleAddToCart(this)">
|
|
||||||
In den Warenkorb
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<a class="btn secondary" href="{{ url_for('login') }}">Zum Buchen einloggen</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</article>-->
|
|
||||||
|
|
||||||
<!-- SCHULTRÄGER -->
|
<!-- SCHULTRÄGER -->
|
||||||
<article class="package-card">
|
<article class="package-card">
|
||||||
<div class="package-card__header">
|
<div class="package-card__header">
|
||||||
@@ -336,8 +335,8 @@
|
|||||||
|
|
||||||
<div class="custom-modal-actions" id="cartActions">
|
<div class="custom-modal-actions" id="cartActions">
|
||||||
<button class="btn secondary" type="button" onclick="closeCartModal()">Weiter umsehen</button>
|
<button class="btn secondary" type="button" onclick="closeCartModal()">Weiter umsehen</button>
|
||||||
<button class="btn outline" type="button" onclick="goToCheckout('consultation')">Beratungstermin anfragen</button>
|
<button class="btn outline" type="button" id="btnConsultation" onclick="goToCheckout('consultation')">Beratungstermin anfragen</button>
|
||||||
<button class="btn" type="button" onclick="goToCheckout('payment')">Jetzt Zahlungspflichtig buchen</button>
|
<button class="btn" type="button" id="btnCheckout" onclick="goToCheckout('payment')">Jetzt Zahlungspflichtig buchen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -383,7 +382,10 @@
|
|||||||
if(item.price > 0) total += item.price;
|
if(item.price > 0) total += item.price;
|
||||||
if(item.price === 0) hasIndividualPrice = true;
|
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 += `
|
container.innerHTML += `
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
@@ -404,6 +406,28 @@
|
|||||||
} else {
|
} else {
|
||||||
totalPriceEl.innerText = `${total} € / Jahr`;
|
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
|
// Artikel zum Warenkorb hinzufügen
|
||||||
@@ -414,6 +438,12 @@
|
|||||||
return;
|
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
|
// Konflikte mit Startpaket abfangen
|
||||||
const hasStartpaket = cart.find(i => i.id === 'startpaket');
|
const hasStartpaket = cart.find(i => i.id === 'startpaket');
|
||||||
if (hasStartpaket && (id === 'inventarsystem' || id === 'buecherei')) {
|
if (hasStartpaket && (id === 'inventarsystem' || id === 'buecherei')) {
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
|
|
||||||
<form id="checkout-form" method="POST" action="{{ url_for('booking_payment') }}">
|
<form id="checkout-form" method="POST" action="{{ url_for('booking_payment') }}">
|
||||||
<input type="hidden" name="package" value="{{ package_key }}">
|
<input type="hidden" name="package" value="{{ package_key }}">
|
||||||
|
<input type="hidden" name="flow" value="{{ flow }}">
|
||||||
|
|
||||||
<div class="booking-review__form-grid">
|
<div class="booking-review__form-grid">
|
||||||
<div class="booking-review__section-title">1. Angaben zur Schule & Ansprechpartner</div>
|
<div class="booking-review__section-title">1. Angaben zur Schule & Ansprechpartner</div>
|
||||||
@@ -524,17 +525,16 @@
|
|||||||
let backendData = null; // Speichert das finale Backend-Resultat
|
let backendData = null; // Speichert das finale Backend-Resultat
|
||||||
let isFailed = false;
|
let isFailed = false;
|
||||||
|
|
||||||
// Echte Log-Meldungen, die exakt alle 10 Sekunden durchgeschaltet werden
|
|
||||||
const statusSteps = [
|
const statusSteps = [
|
||||||
{ atMs: 0, msg: "[INFO] Verarbeite Trigger: Starte Provisionierung für " + slug + "..." },
|
{ atMs: 0, msg: "[INFO] Processing trigger: Starting deployment for " + slug + "..." },
|
||||||
{ atMs: 10000, msg: "[INFO] Prüfe freie Ports... Verwende Port 10004 für Tenant " + slug },
|
{ atMs: 10000, msg: "[INFO] Checking available resources and allocating network ports..." },
|
||||||
{ atMs: 20000, msg: "Requesting a certificate for " + slug + ".invario-software.de..." },
|
{ atMs: 20000, msg: "[INFO] Requesting security certificate for " + slug + "..." },
|
||||||
{ atMs: 30000, msg: "Successfully deployed certificate to Nginx. Restarting app container..." },
|
{ atMs: 30000, msg: "[INFO] Deploying certificate to proxy layer. Restarting service..." },
|
||||||
{ atMs: 40000, msg: "Container inventarsystem-app-1 Recreated. Waiting for MongoDB/Redis..." },
|
{ atMs: 40000, msg: "[INFO] Service containers recreated. Waiting for backend dependencies..." },
|
||||||
{ atMs: 50000, msg: "Initializing database for " + slug + "... Default admin created." },
|
{ atMs: 50000, msg: "[INFO] Initializing storage for " + slug + "... Default admin created." },
|
||||||
{ atMs: 60000, msg: "Module configurations updated successfully (library=on)..." },
|
{ atMs: 60000, msg: "[INFO] Configurations updated successfully..." },
|
||||||
{ atMs: 70000, msg: "Rebuilding and/or restarting app container using docker-compose..." },
|
{ atMs: 70000, msg: "[INFO] Rebuilding and restarting application stack..." },
|
||||||
{ atMs: 80000, msg: "[INFO] Bereinige verwaiste App-Container. Cleaning up old temporary files..." }
|
{ atMs: 80000, msg: "[INFO] Cleaning up temporary files and orphan processes..." }
|
||||||
];
|
];
|
||||||
|
|
||||||
// 1. Hintergrund-Polling: Fragt den echten Status ab, OHNE die UI abzubrechen
|
// 1. Hintergrund-Polling: Fragt den echten Status ab, OHNE die UI abzubrechen
|
||||||
|
|||||||
Reference in New Issue
Block a user