agnes-the-ai-analyst/webapp/sync_settings_service.py
Petr 86edd27655 Extract Jira into connectors/jira module
Move all Jira-specific code into a self-contained connector module:
- 22 files moved via git mv (transform, service, webhook, scripts,
  systemd units, tests, docs, bin helper)
- All imports updated to use connectors.jira.* paths
- Jira is now conditional: auto-detected via JIRA_DOMAIN env var
- Webapp registers Jira blueprint only when available
- Health service monitors Jira timers only when enabled
- Profiler loads Jira tables dynamically from filesystem
- Sync settings uses config-driven dependency validation
- Renamed keboola_platform_url -> custom_url in transform
- Updated deploy.sh, sudoers-deploy, backfill_gap.sh paths
- Fixed pytest.ini to skip live tests by default
2026-03-09 11:17:50 +01:00

197 lines
6.1 KiB
Python

"""
Sync settings service for the webapp.
Reads/writes shared JSON files in /data/notifications/ to manage
user sync settings (which datasets to sync).
"""
import json
import logging
import os
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
NOTIFICATIONS_DIR = "/data/notifications"
SYNC_SETTINGS_FILE = os.path.join(NOTIFICATIONS_DIR, "sync_settings.json")
def _load_dataset_config():
"""Load dataset configuration from instance config."""
try:
from config.loader import load_instance_config, get_instance_value
config = load_instance_config()
datasets = get_instance_value(config, "datasets", default={})
if datasets:
defaults = {k: False for k in datasets}
return defaults, datasets
except Exception:
pass
# Fallback: empty (no optional datasets)
return {}, {}
DEFAULT_SETTINGS, DATASET_INFO = _load_dataset_config()
def _read_json(path: str) -> dict:
"""Read a JSON file, return empty dict if not found or invalid."""
try:
with open(path, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _write_json(path: str, data: dict) -> None:
"""Write JSON data to file atomically."""
dir_path = os.path.dirname(path)
os.makedirs(dir_path, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
os.chmod(tmp_path, 0o660) # group-readable for data-ops
os.replace(tmp_path, path)
except Exception:
os.unlink(tmp_path)
raise
def get_sync_settings(username: str) -> dict[str, Any]:
"""Get sync settings for a user.
Returns dict with:
- datasets: {name: enabled} for each dataset
- metadata: {name: {label, description, size_hint, requires}}
"""
all_settings = _read_json(SYNC_SETTINGS_FILE)
user_settings = all_settings.get(username, {})
# Merge with defaults
datasets = dict(DEFAULT_SETTINGS)
datasets.update(user_settings.get("datasets", {}))
return {
"datasets": datasets,
"metadata": DATASET_INFO,
"updated_at": user_settings.get("updated_at"),
}
def update_sync_settings(username: str, settings: dict) -> tuple[bool, str]:
"""Update sync settings for a user.
Args:
username: The username to update settings for
settings: Dict with dataset names as keys and bool enabled as values
Returns:
(success, message) tuple
"""
# Validate settings
for key, value in settings.items():
if key not in DEFAULT_SETTINGS:
return False, f"Unknown dataset: {key}"
if not isinstance(value, bool):
return False, f"Invalid value for {key}: must be boolean"
# Read current settings and merge (so partial updates don't reset other datasets)
all_settings = _read_json(SYNC_SETTINGS_FILE)
existing = all_settings.get(username, {}).get("datasets", dict(DEFAULT_SETTINGS))
existing.update(settings)
# Validate dependencies on merged state (from instance config)
for key, info in DATASET_INFO.items():
requires = info.get("requires") if isinstance(info, dict) else None
if requires and existing.get(key) and not existing.get(requires):
return False, f"{key} requires {requires} to be enabled"
# Update user's settings
all_settings[username] = {
"datasets": existing,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
# Write back
_write_json(SYNC_SETTINGS_FILE, all_settings)
# Regenerate user's config file
success = _regenerate_user_config(username, existing)
if not success:
logger.warning(f"Failed to regenerate config for {username}")
# Don't fail - settings are saved, just config generation failed
logger.info(f"Updated sync settings for '{username}': {existing}")
return True, "Settings saved. Changes take effect on next sync."
def _regenerate_user_config(username: str, settings: dict) -> bool:
"""Regenerate ~/.sync_settings.yaml for a user on the server.
Returns True on success, False on failure.
"""
# Generate YAML content
yaml_content = generate_user_config_yaml(settings)
# Write to user's home directory on server
user_config_path = f"/home/{username}/.sync_settings.yaml"
try:
# Use sudo to write as the target user
# This requires webapp user to have sudoers entry for this specific operation
# IMPORTANT: Must use /tmp/ explicitly - sudoers rule only allows /tmp/*.yaml
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, dir="/tmp") as f:
f.write(yaml_content)
tmp_path = f.name
# Copy to user's home with correct ownership
result = subprocess.run(
["/usr/bin/sudo", "-n", "/usr/bin/install", "-o", username, "-g", username, "-m", "644", tmp_path, user_config_path],
capture_output=True,
text=True,
timeout=10,
)
os.unlink(tmp_path)
if result.returncode != 0:
logger.error(f"Failed to install config for {username}: {result.stderr}")
return False
return True
except subprocess.TimeoutExpired:
logger.error(f"Timeout installing config for {username}")
return False
except Exception as e:
logger.error(f"Error installing config for {username}: {e}")
return False
def generate_user_config_yaml(settings: dict) -> str:
"""Generate YAML content for sync config.
Args:
settings: Dict with dataset names and enabled status
Returns:
YAML string content
"""
lines = [
"# Data Analyst - Sync Configuration",
"# Managed by web portal - changes here may be overwritten",
"",
"datasets:",
]
for dataset, enabled in sorted(settings.items()):
value = "true" if enabled else "false"
lines.append(f" {dataset}: {value}")
lines.append("")
return "\n".join(lines)