""" Centralised settings for Kanbante. Every setting is read from the environment at call time (no import-time freezing), so tests and operators can change them without code edits. Environment variables: GITEA_URL Base URL of the Gitea instance. GITEA_TOKEN Gitea API token (read/write issues). KANBANTE_DB_PATH Path of the local SQLite database. KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth). KANBANTE_AUTH_PASSWORD Login password. KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured. KANBANTE_SESSION_SECRET HMAC key for the login session cookie (default: derived from the credentials — rotating the password invalidates sessions). DRACO_SESSION_SECRET Shared secret of the dracodev SSO cookie (``draco_session``, emitted by ObsiGate — Fusion ACC-3). Empty (default) = SSO bridge disabled. ``DRACO_SESSION_SECRET_PREV`` accepts the previous secret while rotating, ``DRACO_SSO_MACHINE_KEY`` authenticates the revocation-list fetch and ``DRACO_SSO_URL`` points at the emitter (default https://og.dracodev.net). KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot refreshes (chantier B, default 120; 0 disables the refresher). KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many days counts as "active" (default 365). KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30). KANBANTE_MAX_NOTE_CHARS Maximum size of a project note (default 20000). KANBANTE_SYNC_GITEA "1" (default) = moving a card to/from "done" closes / reopens the Gitea issue. KANBANTE_WIP_LIMITS Comma list "column:limit" (e.g. "in_progress:5,review:3"). Columns not listed are unlimited. Soft limits: the UI warns, moves are never blocked. KANBANTE_AGING_DAYS Days in the same column before a card is flagged as aging (default 7; "stale" at 2x). KANBANTE_WEBHOOK_SECRET Shared secret for the Gitea webhook (POST /webhooks/gitea). When set, the X-Gitea-Signature HMAC is enforced. KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate columns to Gitea labels named kanban:. KANBANTE_NTFY_URL Base URL of an ntfy server (e.g. https://ntfy.sh or self-hosted). KANBANTE_NTFY_TOPIC ntfy topic for the daily briefing. KANBANTE_NTFY_TOKEN Optional Bearer token for ntfy. KANBANTE_BRIEFING_TIME "HH:MM" (server local time) at which the daily briefing is sent to ntfy. Empty (default) = no automatic send, the /briefing page stays available. KANBANTE_CAPTURE_REPO Default repo for quick capture (POST /api/capture, the /capture page and the ntfy capture listener), e.g. "Projets/kanbante". Empty = captures must name their repo explicitly. KANBANTE_CAPTURE_TOKEN Dedicated token for POST /api/capture (header X-Capture-Token or Bearer). Empty (default) = the endpoint is disabled (503). Distinct from the Gitea token; revocable on its own. KANBANTE_CAPTURE_NTFY_TOPIC ntfy topic listened to for captures: each message published there becomes an issue in KANBANTE_CAPTURE_REPO. Uses KANBANTE_NTFY_URL/_TOKEN as the server. Empty (default) = no listener. KANBANTE_DIGEST_TIME "HH:MM" at which the weekly digest is composed (and ntfy-notified) on KANBANTE_DIGEST_WEEKDAY. The digest is NEVER auto-delivered: it waits for a one-click approval on /digest. KANBANTE_DIGEST_WEEKDAY 0 = Monday … 6 = Sunday (default 0). KANBANTE_DIGEST_WEBHOOK_URL Generic webhook receiving the digest as JSON {title, markdown, week}. KANBANTE_OBSIGATE_MCP_URL URL of ObsiGate's MCP endpoint; the digest is delivered by calling a note-creation tool on it (client MCP). KANBANTE_OBSIGATE_MCP_TOKEN Optional Bearer token for that MCP. KANBANTE_OBSIGATE_MCP_TOOL Name of the ObsiGate tool to call (see /digest for the live tool list). KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool; "{title}" and "{content}" placeholders are substituted (JSON-escaped). KANBANTE_BRIEFING_INCLUDE_STANDUP "1" appends the automatic standup (issue #6) to the morning briefing. KANBANTE_RULES Automation rules (issue #8) as a JSON array: [{"name": ..., "when": {"column": "review", "min_age_days": 3}, "then": {"notify": true}}]. Conditions: column, min_age_days, label, wip_over. Actions: add_label, notify, move_top. A rule can never close an issue. KANBANTE_ZOMBIE_DAYS Days without any activity after which an open card is a "zombie" on /health (issue #9). Default 30. """ import os from dataclasses import dataclass from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent APP_VERSION = "0.24.0" def _env_bool(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in ("1", "true", "yes", "on") def _env_int(name: str, default: int) -> int: raw = os.getenv(name) try: return int(raw) if raw is not None else default except ValueError: return default def _env_float(name: str, default: float) -> float: raw = os.getenv(name) try: return float(raw) if raw is not None else default except ValueError: return default def _env_wip_limits(name: str) -> dict[str, int]: limits: dict[str, int] = {} for chunk in os.getenv(name, "").split(","): column, sep, value = chunk.partition(":") column = column.strip() if sep and column and value.strip().isdigit(): limits[column] = int(value.strip()) return limits @dataclass(frozen=True) class Settings: gitea_url: str = "https://git.dracodev.net" gitea_token: str = "" db_path: str = "~/.kanbante/kanbante.db" auth_user: str = "" auth_password: str = "" require_auth: bool = False active_window_days: int = 365 cache_ttl: float = 30.0 max_note_chars: int = 20_000 sync_gitea: bool = True wip_limits: dict | None = None aging_days: int = 7 webhook_secret: str = "" sync_labels: bool = True ntfy_url: str = "" ntfy_topic: str = "" ntfy_token: str = "" briefing_time: str = "" capture_repo: str = "" capture_token: str = "" capture_ntfy_topic: str = "" digest_time: str = "" digest_weekday: int = 0 digest_webhook_url: str = "" obsigate_mcp_url: str = "" obsigate_mcp_token: str = "" obsigate_mcp_tool: str = "" obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}' briefing_include_standup: bool = False rules_raw: str = "" zombie_days: int = 30 session_secret: str = "" snapshot_interval: int = 120 draco_secret: str = "" draco_secret_prev: str = "" sso_machine_key: str = "" sso_url: str = "https://og.dracodev.net" archive_suggest_days: int = 180 @property def auth_enabled(self) -> bool: return bool(self.auth_user and self.auth_password) def get_settings() -> Settings: """Effective settings: environment, overlaid by the admin panel's database values (0.24.0 — see OVERRIDABLE_SETTINGS).""" return apply_db_overrides(_env_settings()) def _env_settings() -> Settings: """Build settings from the current environment only.""" return Settings( gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"), gitea_token=os.getenv("GITEA_TOKEN", ""), db_path=os.getenv( "KANBANTE_DB_PATH", os.path.expanduser("~/.kanbante/kanbante.db") ), auth_user=os.getenv("KANBANTE_AUTH_USER", ""), auth_password=os.getenv("KANBANTE_AUTH_PASSWORD", ""), require_auth=_env_bool("KANBANTE_REQUIRE_AUTH", False), active_window_days=_env_int("KANBANTE_ACTIVE_WINDOW_DAYS", 365), cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0), max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000), sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True), wip_limits=_env_wip_limits("KANBANTE_WIP_LIMITS"), aging_days=_env_int("KANBANTE_AGING_DAYS", 7), webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""), sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True), ntfy_url=os.getenv("KANBANTE_NTFY_URL", ""), ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""), ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""), briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""), capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""), capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""), capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""), digest_time=os.getenv("KANBANTE_DIGEST_TIME", ""), digest_weekday=_env_int("KANBANTE_DIGEST_WEEKDAY", 0), digest_webhook_url=os.getenv("KANBANTE_DIGEST_WEBHOOK_URL", ""), obsigate_mcp_url=os.getenv("KANBANTE_OBSIGATE_MCP_URL", ""), obsigate_mcp_token=os.getenv("KANBANTE_OBSIGATE_MCP_TOKEN", ""), obsigate_mcp_tool=os.getenv("KANBANTE_OBSIGATE_MCP_TOOL", ""), obsigate_mcp_args=os.getenv( "KANBANTE_OBSIGATE_MCP_ARGS", '{"title": "{title}", "content": "{content}"}', ), briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False), rules_raw=os.getenv("KANBANTE_RULES", ""), zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30), session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""), snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120), draco_secret=os.getenv("DRACO_SESSION_SECRET", ""), draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""), sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""), sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"), archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180), ) # ── Surcharge par le panneau d'administration (0.24.0, P3) ── # L'environnement amorce, le panneau gouverne : les valeurs # ci-dessous peuvent être remplacées en base (kv_store, clé # "settings_overrides", JSON) depuis /admin/settings. La # provenance reste affichée dans le panneau. Les secrets et les # réglages d'amorçage (DB, clé maître, session, draco) ne sont # volontairement PAS surchargeables. OVERRIDABLE_SETTINGS: dict[str, type] = { "active_window_days": int, "cache_ttl": float, "snapshot_interval": int, "aging_days": int, "zombie_days": int, "archive_suggest_days": int, "max_note_chars": int, "briefing_time": str, "briefing_include_standup": bool, "digest_time": str, "digest_weekday": int, "capture_repo": str, "sync_labels": bool, "sync_gitea": bool, } def settings_overrides() -> dict: """The raw overrides stored by the admin panel ({} if none).""" try: import json as _json from app import models raw = models.get_kv("settings_overrides") data = _json.loads(raw) if raw else {} return data if isinstance(data, dict) else {} except Exception: return {} def apply_db_overrides(settings: Settings) -> Settings: """Overlay the admin-panel values on the env-built settings.""" overrides = settings_overrides() if not overrides: return settings import dataclasses valid: dict = {} for key, value in overrides.items(): converter = OVERRIDABLE_SETTINGS.get(key) if converter is None or value is None: continue try: if converter is bool: valid[key] = str(value).lower() in ("1", "true", "on", "oui") else: valid[key] = converter(value) except (TypeError, ValueError): continue return dataclasses.replace(settings, **valid) if valid else settings