571 lines
28 KiB
Python
571 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
from ..db.database import Database
|
|
from ..db.storage import REQUIRED_TASKS, Storage
|
|
from ..metrics.metrics import record_task_run, set_access_request_counts
|
|
from ..services.firefly import firefly
|
|
from ..services.keycloak_admin import keycloak_admin
|
|
from ..services.mailer import MailerError, mailer
|
|
from ..services.mailu import mailu
|
|
from ..services.nextcloud import nextcloud
|
|
from ..services.vaultwarden import vaultwarden
|
|
from ..services.wger import wger
|
|
from ..settings import settings
|
|
from ..utils.errors import safe_error_detail
|
|
from ..utils.logging import get_logger
|
|
from ..utils.passwords import random_password
|
|
|
|
|
|
MAILU_EMAIL_ATTR = "mailu_email"
|
|
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
|
|
MAILU_ENABLED_ATTR = "mailu_enabled"
|
|
WGER_PASSWORD_ATTR = "wger_password"
|
|
WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at"
|
|
FIREFLY_PASSWORD_ATTR = "firefly_password"
|
|
FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at"
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvisionOutcome:
|
|
ok: bool
|
|
status: str
|
|
|
|
|
|
def _advisory_lock_id(request_code: str) -> int:
|
|
digest = hashlib.sha256(request_code.encode("utf-8")).digest()
|
|
return int.from_bytes(digest[:8], "big", signed=True)
|
|
|
|
|
|
def _extract_attr(attrs: Any, key: str) -> str:
|
|
if not isinstance(attrs, dict):
|
|
return ""
|
|
raw = attrs.get(key)
|
|
if isinstance(raw, list):
|
|
for item in raw:
|
|
if isinstance(item, str) and item.strip():
|
|
return item.strip()
|
|
return ""
|
|
if isinstance(raw, str) and raw.strip():
|
|
return raw.strip()
|
|
return ""
|
|
|
|
|
|
class ProvisioningManager:
|
|
def __init__(self, db: Database, storage: Storage) -> None:
|
|
self._db = db
|
|
self._storage = storage
|
|
self._thread: threading.Thread | None = None
|
|
self._stop_event = threading.Event()
|
|
|
|
def start(self) -> None:
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._stop_event.clear()
|
|
self._thread = threading.Thread(target=self._run_loop, name="ariadne-provision", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop_event.set()
|
|
if self._thread:
|
|
self._thread.join(timeout=5)
|
|
|
|
def _run_loop(self) -> None:
|
|
while not self._stop_event.is_set():
|
|
try:
|
|
self._sync_status_metrics()
|
|
except Exception:
|
|
pass
|
|
if not keycloak_admin.ready():
|
|
time.sleep(settings.provision_poll_interval_sec)
|
|
continue
|
|
candidates = self._storage.list_provision_candidates()
|
|
for request in candidates:
|
|
self.provision_access_request(request.request_code)
|
|
time.sleep(settings.provision_poll_interval_sec)
|
|
|
|
def _sync_status_metrics(self) -> None:
|
|
counts = self._db.fetchall(
|
|
"SELECT status, COUNT(*) AS count FROM access_requests GROUP BY status"
|
|
)
|
|
payload: dict[str, int] = {}
|
|
for row in counts:
|
|
status = row.get("status")
|
|
count = row.get("count")
|
|
if isinstance(status, str) and isinstance(count, int):
|
|
payload[status] = count
|
|
set_access_request_counts(payload)
|
|
|
|
def provision_access_request(self, request_code: str) -> ProvisionOutcome:
|
|
if not request_code:
|
|
return ProvisionOutcome(ok=False, status="unknown")
|
|
if not keycloak_admin.ready():
|
|
return ProvisionOutcome(ok=False, status="accounts_building")
|
|
|
|
required_tasks = list(REQUIRED_TASKS)
|
|
logger.info(
|
|
"provisioning started",
|
|
extra={"event": "provision_start", "request_code": request_code},
|
|
)
|
|
|
|
with self._db.connection() as conn:
|
|
lock_id = _advisory_lock_id(request_code)
|
|
locked_row = conn.execute("SELECT pg_try_advisory_lock(%s) AS locked", (lock_id,)).fetchone()
|
|
if not locked_row or not locked_row.get("locked"):
|
|
return ProvisionOutcome(ok=False, status="accounts_building")
|
|
|
|
try:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT username,
|
|
contact_email,
|
|
email_verified_at,
|
|
status,
|
|
initial_password,
|
|
initial_password_revealed_at,
|
|
provision_attempted_at,
|
|
approval_flags
|
|
FROM access_requests
|
|
WHERE request_code = %s
|
|
""",
|
|
(request_code,),
|
|
).fetchone()
|
|
if not row:
|
|
return ProvisionOutcome(ok=False, status="unknown")
|
|
|
|
username = str(row.get("username") or "")
|
|
contact_email = str(row.get("contact_email") or "")
|
|
email_verified_at = row.get("email_verified_at")
|
|
status = str(row.get("status") or "")
|
|
initial_password = row.get("initial_password")
|
|
revealed_at = row.get("initial_password_revealed_at")
|
|
attempted_at = row.get("provision_attempted_at")
|
|
approval_flags = row.get("approval_flags") if isinstance(row.get("approval_flags"), list) else []
|
|
|
|
if status == "approved":
|
|
conn.execute(
|
|
"""
|
|
UPDATE access_requests
|
|
SET status = 'accounts_building'
|
|
WHERE request_code = %s AND status = 'approved'
|
|
""",
|
|
(request_code,),
|
|
)
|
|
status = "accounts_building"
|
|
|
|
if status not in {"accounts_building", "awaiting_onboarding", "ready"}:
|
|
return ProvisionOutcome(ok=False, status=status or "unknown")
|
|
|
|
self._ensure_task_rows(conn, request_code, required_tasks)
|
|
|
|
if status == "accounts_building":
|
|
now = datetime.now(timezone.utc)
|
|
if isinstance(attempted_at, datetime):
|
|
if attempted_at.tzinfo is None:
|
|
attempted_at = attempted_at.replace(tzinfo=timezone.utc)
|
|
age_sec = (now - attempted_at).total_seconds()
|
|
if age_sec < settings.provision_retry_cooldown_sec:
|
|
return ProvisionOutcome(ok=False, status="accounts_building")
|
|
conn.execute(
|
|
"UPDATE access_requests SET provision_attempted_at = NOW() WHERE request_code = %s",
|
|
(request_code,),
|
|
)
|
|
|
|
user_id = ""
|
|
mailu_email = f"{username}@{settings.mailu_domain}"
|
|
|
|
# Task: ensure Keycloak user exists
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
user = keycloak_admin.find_user(username)
|
|
if not user:
|
|
if not isinstance(email_verified_at, datetime):
|
|
raise RuntimeError("missing verified email address")
|
|
email = contact_email.strip()
|
|
if not email:
|
|
raise RuntimeError("missing verified email address")
|
|
existing_email_user = keycloak_admin.find_user_by_email(email)
|
|
if existing_email_user and (existing_email_user.get("username") or "") != username:
|
|
raise RuntimeError("email is already associated with an existing Atlas account")
|
|
payload = {
|
|
"username": username,
|
|
"enabled": True,
|
|
"email": email,
|
|
"emailVerified": True,
|
|
"requiredActions": [],
|
|
"attributes": {
|
|
MAILU_EMAIL_ATTR: [mailu_email],
|
|
MAILU_ENABLED_ATTR: ["true"],
|
|
},
|
|
}
|
|
created_id = keycloak_admin.create_user(payload)
|
|
user = keycloak_admin.get_user(created_id)
|
|
user_id = str((user or {}).get("id") or "")
|
|
if not user_id:
|
|
raise RuntimeError("user id missing")
|
|
|
|
try:
|
|
full = keycloak_admin.get_user(user_id)
|
|
attrs = full.get("attributes") or {}
|
|
actions = full.get("requiredActions")
|
|
if isinstance(actions, list) and "CONFIGURE_TOTP" in actions:
|
|
new_actions = [a for a in actions if a != "CONFIGURE_TOTP"]
|
|
keycloak_admin.update_user_safe(user_id, {"requiredActions": new_actions})
|
|
if isinstance(attrs, dict):
|
|
existing = _extract_attr(attrs, MAILU_EMAIL_ATTR)
|
|
if existing:
|
|
mailu_email = existing
|
|
else:
|
|
mailu_email = f"{username}@{settings.mailu_domain}"
|
|
keycloak_admin.set_user_attribute(username, MAILU_EMAIL_ATTR, mailu_email)
|
|
enabled_value = _extract_attr(attrs, MAILU_ENABLED_ATTR)
|
|
if enabled_value.lower() not in {"1", "true", "yes", "y", "on"}:
|
|
keycloak_admin.set_user_attribute(username, MAILU_ENABLED_ATTR, "true")
|
|
except Exception:
|
|
mailu_email = f"{username}@{settings.mailu_domain}"
|
|
|
|
self._upsert_task(conn, request_code, "keycloak_user", "ok", None)
|
|
self._record_task(request_code, "keycloak_user", "ok", None, start)
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to ensure user")
|
|
self._upsert_task(conn, request_code, "keycloak_user", "error", detail)
|
|
self._record_task(request_code, "keycloak_user", "error", detail, start)
|
|
|
|
if not user_id:
|
|
return ProvisionOutcome(ok=False, status="accounts_building")
|
|
|
|
# Task: set initial password for Keycloak
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
should_reset = status == "accounts_building" and revealed_at is None
|
|
password_value: str | None = None
|
|
|
|
if should_reset:
|
|
if isinstance(initial_password, str) and initial_password:
|
|
password_value = initial_password
|
|
elif initial_password is None:
|
|
password_value = random_password(20)
|
|
conn.execute(
|
|
"""
|
|
UPDATE access_requests
|
|
SET initial_password = %s
|
|
WHERE request_code = %s AND initial_password IS NULL
|
|
""",
|
|
(password_value, request_code),
|
|
)
|
|
initial_password = password_value
|
|
|
|
if password_value:
|
|
keycloak_admin.reset_password(user_id, password_value, temporary=False)
|
|
|
|
if isinstance(initial_password, str) and initial_password:
|
|
self._upsert_task(conn, request_code, "keycloak_password", "ok", None)
|
|
self._record_task(request_code, "keycloak_password", "ok", None, start)
|
|
elif revealed_at is not None:
|
|
detail = "initial password already revealed"
|
|
self._upsert_task(conn, request_code, "keycloak_password", "ok", detail)
|
|
self._record_task(request_code, "keycloak_password", "ok", detail, start)
|
|
else:
|
|
raise RuntimeError("initial password missing")
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to set password")
|
|
self._upsert_task(conn, request_code, "keycloak_password", "error", detail)
|
|
self._record_task(request_code, "keycloak_password", "error", detail, start)
|
|
|
|
# Task: group membership
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
approved_flags = [flag for flag in approval_flags if flag in settings.allowed_flag_groups]
|
|
groups = list(dict.fromkeys(settings.default_user_groups + approved_flags))
|
|
for group_name in groups:
|
|
gid = keycloak_admin.get_group_id(group_name)
|
|
if not gid:
|
|
raise RuntimeError(f"group missing: {group_name}")
|
|
keycloak_admin.add_user_to_group(user_id, gid)
|
|
self._upsert_task(conn, request_code, "keycloak_groups", "ok", None)
|
|
self._record_task(request_code, "keycloak_groups", "ok", None, start)
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to add groups")
|
|
self._upsert_task(conn, request_code, "keycloak_groups", "error", detail)
|
|
self._record_task(request_code, "keycloak_groups", "error", detail, start)
|
|
|
|
# Task: ensure mailu app password exists
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
full = keycloak_admin.get_user(user_id)
|
|
attrs = full.get("attributes") or {}
|
|
existing = _extract_attr(attrs, MAILU_APP_PASSWORD_ATTR)
|
|
if not existing:
|
|
keycloak_admin.set_user_attribute(username, MAILU_APP_PASSWORD_ATTR, random_password())
|
|
self._upsert_task(conn, request_code, "mailu_app_password", "ok", None)
|
|
self._record_task(request_code, "mailu_app_password", "ok", None, start)
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to set mail password")
|
|
self._upsert_task(conn, request_code, "mailu_app_password", "error", detail)
|
|
self._record_task(request_code, "mailu_app_password", "error", detail, start)
|
|
|
|
# Task: trigger Mailu sync
|
|
start = datetime.now(timezone.utc)
|
|
mailbox_ready = True
|
|
try:
|
|
if not settings.mailu_sync_url:
|
|
detail = "sync disabled"
|
|
self._upsert_task(conn, request_code, "mailu_sync", "ok", detail)
|
|
self._record_task(request_code, "mailu_sync", "ok", detail, start)
|
|
else:
|
|
mailu.sync(reason="ariadne_access_approve", force=True)
|
|
mailbox_ready = mailu.wait_for_mailbox(
|
|
mailu_email, settings.mailu_mailbox_wait_timeout_sec
|
|
)
|
|
if not mailbox_ready:
|
|
raise RuntimeError("mailbox not ready")
|
|
self._upsert_task(conn, request_code, "mailu_sync", "ok", None)
|
|
self._record_task(request_code, "mailu_sync", "ok", None, start)
|
|
except Exception as exc:
|
|
mailbox_ready = False
|
|
detail = safe_error_detail(exc, "failed to sync mailu")
|
|
self._upsert_task(conn, request_code, "mailu_sync", "error", detail)
|
|
self._record_task(request_code, "mailu_sync", "error", detail, start)
|
|
|
|
if not mailbox_ready:
|
|
logger.info(
|
|
"mailbox not ready after sync",
|
|
extra={"event": "mailu_mailbox_wait", "request_code": request_code, "status": "retry"},
|
|
)
|
|
return ProvisionOutcome(ok=False, status="accounts_building")
|
|
|
|
# Task: trigger Nextcloud mail sync
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
if not settings.nextcloud_namespace or not settings.nextcloud_mail_sync_cronjob:
|
|
detail = "sync disabled"
|
|
self._upsert_task(conn, request_code, "nextcloud_mail_sync", "ok", detail)
|
|
self._record_task(request_code, "nextcloud_mail_sync", "ok", detail, start)
|
|
else:
|
|
result = nextcloud.sync_mail(username, wait=True)
|
|
if isinstance(result, dict) and result.get("status") == "ok":
|
|
self._upsert_task(conn, request_code, "nextcloud_mail_sync", "ok", None)
|
|
self._record_task(request_code, "nextcloud_mail_sync", "ok", None, start)
|
|
else:
|
|
status_val = result.get("status") if isinstance(result, dict) else "error"
|
|
detail = str(status_val)
|
|
self._upsert_task(conn, request_code, "nextcloud_mail_sync", "error", detail)
|
|
self._record_task(request_code, "nextcloud_mail_sync", "error", detail, start)
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to sync nextcloud")
|
|
self._upsert_task(conn, request_code, "nextcloud_mail_sync", "error", detail)
|
|
self._record_task(request_code, "nextcloud_mail_sync", "error", detail, start)
|
|
|
|
# Task: ensure wger account exists
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
full = keycloak_admin.get_user(user_id)
|
|
attrs = full.get("attributes") or {}
|
|
wger_password = _extract_attr(attrs, WGER_PASSWORD_ATTR)
|
|
wger_password_updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR)
|
|
|
|
if not wger_password:
|
|
wger_password = random_password(20)
|
|
keycloak_admin.set_user_attribute(username, WGER_PASSWORD_ATTR, wger_password)
|
|
|
|
if not wger_password_updated_at:
|
|
result = wger.sync_user(username, mailu_email, wger_password, wait=True)
|
|
status_val = result.get("status") if isinstance(result, dict) else "error"
|
|
if status_val != "ok":
|
|
raise RuntimeError(f"wger sync {status_val}")
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
keycloak_admin.set_user_attribute(username, WGER_PASSWORD_UPDATED_ATTR, now_iso)
|
|
|
|
self._upsert_task(conn, request_code, "wger_account", "ok", None)
|
|
self._record_task(request_code, "wger_account", "ok", None, start)
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to provision wger")
|
|
self._upsert_task(conn, request_code, "wger_account", "error", detail)
|
|
self._record_task(request_code, "wger_account", "error", detail, start)
|
|
|
|
# Task: ensure firefly account exists
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
full = keycloak_admin.get_user(user_id)
|
|
attrs = full.get("attributes") or {}
|
|
firefly_password = _extract_attr(attrs, FIREFLY_PASSWORD_ATTR)
|
|
firefly_password_updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR)
|
|
|
|
if not firefly_password:
|
|
firefly_password = random_password(24)
|
|
keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_ATTR, firefly_password)
|
|
|
|
if not firefly_password_updated_at:
|
|
result = firefly.sync_user(mailu_email, firefly_password, wait=True)
|
|
status_val = result.get("status") if isinstance(result, dict) else "error"
|
|
if status_val != "ok":
|
|
raise RuntimeError(f"firefly sync {status_val}")
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_UPDATED_ATTR, now_iso)
|
|
|
|
self._upsert_task(conn, request_code, "firefly_account", "ok", None)
|
|
self._record_task(request_code, "firefly_account", "ok", None, start)
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to provision firefly")
|
|
self._upsert_task(conn, request_code, "firefly_account", "error", detail)
|
|
self._record_task(request_code, "firefly_account", "error", detail, start)
|
|
|
|
# Task: ensure Vaultwarden account exists (invite flow)
|
|
start = datetime.now(timezone.utc)
|
|
try:
|
|
if not mailu.wait_for_mailbox(mailu_email, settings.mailu_mailbox_wait_timeout_sec):
|
|
try:
|
|
mailu.sync(reason="ariadne_vaultwarden_retry", force=True)
|
|
except Exception:
|
|
pass
|
|
if not mailu.wait_for_mailbox(mailu_email, settings.mailu_mailbox_wait_timeout_sec):
|
|
raise RuntimeError("mailbox not ready")
|
|
|
|
result = vaultwarden.invite_user(mailu_email)
|
|
if result.ok:
|
|
self._upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status)
|
|
self._record_task(request_code, "vaultwarden_invite", "ok", result.status, start)
|
|
else:
|
|
detail = result.detail or result.status
|
|
self._upsert_task(conn, request_code, "vaultwarden_invite", "error", detail)
|
|
self._record_task(request_code, "vaultwarden_invite", "error", detail, start)
|
|
|
|
try:
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
keycloak_admin.set_user_attribute(username, "vaultwarden_email", mailu_email)
|
|
keycloak_admin.set_user_attribute(username, "vaultwarden_status", result.status)
|
|
keycloak_admin.set_user_attribute(username, "vaultwarden_synced_at", now_iso)
|
|
except Exception:
|
|
pass
|
|
except Exception as exc:
|
|
detail = safe_error_detail(exc, "failed to provision vaultwarden")
|
|
self._upsert_task(conn, request_code, "vaultwarden_invite", "error", detail)
|
|
self._record_task(request_code, "vaultwarden_invite", "error", detail, start)
|
|
|
|
if self._all_tasks_ok(conn, request_code, required_tasks):
|
|
conn.execute(
|
|
"""
|
|
UPDATE access_requests
|
|
SET status = 'awaiting_onboarding'
|
|
WHERE request_code = %s AND status = 'accounts_building'
|
|
""",
|
|
(request_code,),
|
|
)
|
|
self._send_welcome_email(request_code, username, contact_email)
|
|
logger.info(
|
|
"provisioning complete",
|
|
extra={
|
|
"event": "provision_complete",
|
|
"request_code": request_code,
|
|
"username": username,
|
|
"status": "awaiting_onboarding",
|
|
},
|
|
)
|
|
return ProvisionOutcome(ok=True, status="awaiting_onboarding")
|
|
|
|
logger.info(
|
|
"provisioning pending",
|
|
extra={"event": "provision_pending", "request_code": request_code, "status": "accounts_building"},
|
|
)
|
|
return ProvisionOutcome(ok=False, status="accounts_building")
|
|
finally:
|
|
conn.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
|
|
|
|
def _ensure_task_rows(self, conn, request_code: str, tasks: list[str]) -> None:
|
|
if not tasks:
|
|
return
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at)
|
|
SELECT %s, task, 'pending', NULL, NOW()
|
|
FROM UNNEST(%s::text[]) AS task
|
|
ON CONFLICT (request_code, task) DO NOTHING
|
|
""",
|
|
(request_code, tasks),
|
|
)
|
|
|
|
def _upsert_task(self, conn, request_code: str, task: str, status: str, detail: str | None = None) -> None:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at)
|
|
VALUES (%s, %s, %s, %s, NOW())
|
|
ON CONFLICT (request_code, task)
|
|
DO UPDATE SET status = EXCLUDED.status, detail = EXCLUDED.detail, updated_at = NOW()
|
|
""",
|
|
(request_code, task, status, detail),
|
|
)
|
|
|
|
def _task_statuses(self, conn, request_code: str) -> dict[str, str]:
|
|
rows = conn.execute(
|
|
"SELECT task, status FROM access_request_tasks WHERE request_code = %s",
|
|
(request_code,),
|
|
).fetchall()
|
|
output: dict[str, str] = {}
|
|
for row in rows:
|
|
task = row.get("task") if isinstance(row, dict) else None
|
|
status = row.get("status") if isinstance(row, dict) else None
|
|
if isinstance(task, str) and isinstance(status, str):
|
|
output[task] = status
|
|
return output
|
|
|
|
def _all_tasks_ok(self, conn, request_code: str, tasks: list[str]) -> bool:
|
|
statuses = self._task_statuses(conn, request_code)
|
|
for task in tasks:
|
|
if statuses.get(task) != "ok":
|
|
return False
|
|
return True
|
|
|
|
def _record_task(self, request_code: str, task: str, status: str, detail: str | None, started: datetime) -> None:
|
|
finished = datetime.now(timezone.utc)
|
|
duration_sec = (finished - started).total_seconds()
|
|
record_task_run(task, status, duration_sec)
|
|
logger.info(
|
|
"task run",
|
|
extra={
|
|
"event": "task_run",
|
|
"request_code": request_code,
|
|
"task": task,
|
|
"status": status,
|
|
"duration_sec": round(duration_sec, 3),
|
|
"detail": detail or "",
|
|
},
|
|
)
|
|
try:
|
|
self._storage.record_task_run(
|
|
request_code,
|
|
task,
|
|
status,
|
|
detail,
|
|
started,
|
|
finished,
|
|
int(duration_sec * 1000),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _send_welcome_email(self, request_code: str, username: str, contact_email: str) -> None:
|
|
if not settings.welcome_email_enabled:
|
|
return
|
|
if not contact_email:
|
|
return
|
|
try:
|
|
row = self._db.fetchone(
|
|
"SELECT welcome_email_sent_at FROM access_requests WHERE request_code = %s",
|
|
(request_code,),
|
|
)
|
|
if row and row.get("welcome_email_sent_at"):
|
|
return
|
|
onboarding_url = f"{settings.portal_public_base_url}/onboarding?code={request_code}"
|
|
mailer.send_welcome(contact_email, request_code, onboarding_url, username=username)
|
|
self._storage.mark_welcome_sent(request_code)
|
|
except MailerError:
|
|
return
|