303 lines
14 KiB
Python
303 lines
14 KiB
Python
"""
|
|
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:<col>.
|
|
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.2"
|
|
|
|
|
|
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
|