agnes-the-ai-analyst/webapp/account_service.py
Petr c56905d34f Initial commit: OSS data distribution platform
Open-source AI data analyst platform extracted from internal repo.
Includes data sync engine, Keboola adapter, Flask web portal,
server deployment scripts, and configuration templates.
2026-03-08 23:31:28 +01:00

208 lines
6.8 KiB
Python

"""
Account details service for the dashboard widget.
Gathers notification scripts, cron schedule, and last sync info
for a user's account card on the dashboard.
"""
import json
import logging
import re
import subprocess
logger = logging.getLogger(__name__)
NOTIFY_SCRIPTS_BIN = "/usr/local/bin/notify-scripts"
USER_CRONTAB_BIN = "/usr/local/bin/user-crontab"
def _load_username_mapping():
"""Load username mapping from instance config."""
try:
from config.loader import load_instance_config, get_instance_value
config = load_instance_config()
return get_instance_value(config, "username_mapping", default={})
except Exception:
return {}
WEBAPP_TO_SERVER_USERNAME = _load_username_mapping()
SUBPROCESS_TIMEOUT_SECONDS = 10
# Username validation: only allow safe characters (same pattern as user_service.py)
USERNAME_RE = re.compile(r"^[a-z][a-z0-9._-]{0,31}$")
def get_account_details(username: str) -> dict | None:
"""Gather account widget data for dashboard display.
Args:
username: webapp-style username (email-derived)
Returns:
dict with notification_scripts, script_count, cron_schedule,
last_sync_display, sync_datasets_enabled - or None on invalid user.
"""
if not username or not USERNAME_RE.match(username):
return None
server_user = _get_server_username(username)
scripts = _get_notification_scripts(server_user)
cron_schedule = _get_cron_schedule(server_user)
last_sync = _get_last_sync(server_user)
# Enabled optional datasets from sync_settings
sync_datasets = _get_enabled_datasets(username)
return {
"notification_scripts": scripts,
"script_count": len(scripts),
"cron_schedule": cron_schedule,
"last_sync_display": last_sync,
"sync_datasets_enabled": sync_datasets,
}
def _get_server_username(webapp_username: str) -> str:
"""Map webapp username (email-derived) to server home directory name."""
return WEBAPP_TO_SERVER_USERNAME.get(webapp_username, webapp_username)
def _get_notification_scripts(server_user: str) -> list[dict]:
"""Fetch notification scripts list via notify-scripts helper."""
try:
result = subprocess.run(
["/usr/bin/sudo", "-u", server_user, NOTIFY_SCRIPTS_BIN, "list"],
capture_output=True,
text=True,
timeout=SUBPROCESS_TIMEOUT_SECONDS,
)
if result.returncode != 0:
logger.warning(
"notify-scripts list failed for %s: %s", server_user, result.stderr[:300]
)
return []
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
logger.error("notify-scripts list timed out for %s", server_user)
return []
except (json.JSONDecodeError, Exception) as e:
logger.error("notify-scripts list error for %s: %s", server_user, e)
return []
def _get_cron_schedule(server_user: str) -> str | None:
"""Read user's crontab and extract the schedule expression.
Returns human-readable schedule string or None if no crontab.
"""
try:
result = subprocess.run(
["/usr/bin/sudo", "-u", server_user, USER_CRONTAB_BIN],
capture_output=True,
text=True,
timeout=SUBPROCESS_TIMEOUT_SECONDS,
)
if result.returncode != 0:
# returncode 1 = "no crontab for user" - expected case
return None
return _parse_cron_schedule(result.stdout)
except subprocess.TimeoutExpired:
logger.error("user-crontab timed out for %s", server_user)
return None
except Exception as e:
logger.error("user-crontab error for %s: %s", server_user, e)
return None
def _parse_cron_schedule(crontab_output: str) -> str | None:
"""Extract first cron expression from crontab output and humanize it."""
for line in crontab_output.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Cron lines have 5 time fields + command
parts = line.split()
if len(parts) >= 6:
cron_expr = " ".join(parts[:5])
return _humanize_cron(cron_expr)
return None
def _humanize_cron(expr: str) -> str:
"""Convert a cron expression to a human-readable string.
Handles common patterns. Falls back to the raw expression for
anything unusual.
"""
parts = expr.split()
if len(parts) != 5:
return expr
minute, hour, dom, month, dow = parts
# Every N minutes: */N * * * *
if hour == "*" and dom == "*" and month == "*" and dow == "*":
if minute == "*":
return "Every minute"
m = re.match(r"^\*/(\d+)$", minute)
if m:
n = int(m.group(1))
if n == 1:
return "Every minute"
return f"Every {n} minutes"
# Specific minute: e.g. "30 * * * *"
if minute.isdigit():
return "Every hour"
# Every N hours: 0 */N * * *
if minute == "0" and dom == "*" and month == "*" and dow == "*":
m = re.match(r"^\*/(\d+)$", hour)
if m:
n = int(m.group(1))
if n == 1:
return "Every hour"
return f"Every {n} hours"
# Daily: 0 H * * *
if dom == "*" and month == "*" and dow == "*" and minute.isdigit() and hour.isdigit():
return f"Daily at {int(hour):02d}:{int(minute):02d}"
# Fallback - show raw expression
return expr
def _get_last_sync(server_user: str) -> str | None:
"""Get last sync time via notify-scripts sync-status."""
try:
result = subprocess.run(
["/usr/bin/sudo", "-u", server_user, NOTIFY_SCRIPTS_BIN, "sync-status"],
capture_output=True,
text=True,
timeout=SUBPROCESS_TIMEOUT_SECONDS,
)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
if data.get("synced"):
return data.get("elapsed_display")
return None
except subprocess.TimeoutExpired:
logger.error("notify-scripts sync-status timed out for %s", server_user)
return None
except (json.JSONDecodeError, Exception) as e:
logger.error("notify-scripts sync-status error for %s: %s", server_user, e)
return None
def _get_enabled_datasets(webapp_username: str) -> list[str]:
"""Get list of enabled optional datasets from sync settings."""
try:
from .sync_settings_service import get_sync_settings
settings = get_sync_settings(webapp_username)
datasets = settings.get("datasets", {})
return [name for name, enabled in datasets.items() if enabled]
except Exception as e:
logger.error("Failed to read sync settings for %s: %s", webapp_username, e)
return []