CI / test (push) Successful in 2m7s
La constante app/config.py était incrémentée à la main et oubliaée aux releases (0.45.7 vs VERSION=0.45.8) — le deploy auto croyait échouer et notifiait en boucle. VERSION (bumpé par la release) est désormais lue au démarrage, dans l'image comme sur l'hôte.
582 lines
27 KiB
Python
582 lines
27 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.
|
|
AGENT_URL Base URL of the agent service
|
|
(panneau Spark + console). Default
|
|
(official/prod name)
|
|
https://agent.dracodev.net — the dev
|
|
environment declares its ``-dev`` name
|
|
here (e.g. https://agent-dev.dracodev.net).
|
|
Sans variable explicite, la base est
|
|
aussi dérivée de l'hôte de
|
|
AGENT_EVENEMENTS_URL quand il suit le
|
|
motif ``agent<SUFFIXE>.dracodev.net``
|
|
(0.32.2 : sinon le panneau pointait le
|
|
défaut prod alors que le journal était
|
|
en dev — console morte sur la dev).
|
|
AGENT_EVENEMENTS_URL Journal endpoint of the agent
|
|
service (Lot 2, contract ACC-0.2)
|
|
receiving this app's signed events.
|
|
Default: derived from AGENT_URL
|
|
(``<AGENT_URL>/api/v1/evenements``).
|
|
Son hôte sert aussi de source à la
|
|
dérivation d'environnement des URL
|
|
sœurs ci-dessous (voir
|
|
``_url_soeur_draco``), au même titre
|
|
que la base du service (AGENT_URL) —
|
|
dérivation élargie en 0.32.2.
|
|
KANBANTE_PUBLIC_URL This app's own public base URL
|
|
(barre commune self-link, SSO
|
|
``return_to``). Default (official)
|
|
https://kanbante.dracodev.net.
|
|
DRACO_FLOWDECK_URL Public base URL of Flowdeck for the
|
|
common app bar. Default (official)
|
|
https://flowdeck.dracodev.net.
|
|
DRACO_TIKTAK_URL Public base URL of TikTak for the
|
|
common app bar. Default (official)
|
|
https://tiktak.dracodev.net.
|
|
DRACO_SHAARIT_URL Public base URL of ShaarIt for the
|
|
common app bar (barre v0.2.0).
|
|
Default (official)
|
|
https://shaarit.dracodev.net.
|
|
ObsiGate's bar link reuses DRACO_SSO_URL
|
|
(the SSO emitter IS ObsiGate).
|
|
Résolution d'une URL rendue à une
|
|
requête (barre, panneau, CSP), dans
|
|
l'ordre (voir ``resoudre_urls_draco``,
|
|
0.32.2) : 1) la variable explicite
|
|
ci-dessus (prioritaire, contrat
|
|
inchangé) ; 2) l'hôte public de la
|
|
requête, motif fermé
|
|
``(og|flowdeck|tiktak|kanbante|shaarit|agent)
|
|
<SUFFIXE>.dracodev.net`` (suffixe vide
|
|
en prod, ``-dev`` en dev) appliqué à
|
|
toutes les sœurs et à l'agent — jamais
|
|
de suffixe inventé depuis un hôte
|
|
arbitraire ; 3) dérivation depuis les
|
|
adresses de l'agent connues en
|
|
configuration (AGENT_EVENEMENTS_URL
|
|
ou AGENT_URL) — si cet hôte est
|
|
``agent<SUFFIXE>.dracodev.net``,
|
|
l'URL sœur devient
|
|
``https://<slug><SUFFIXE>.
|
|
dracodev.net`` (slugs : og, flowdeck,
|
|
tiktak, kanbante, shaarit, agent) ; hôte hors
|
|
motif (autre domaine, LAN, IP) =
|
|
aucune dérivation, jamais de lien
|
|
deviné ; 4) le défaut officiel (prod).
|
|
Les contextes sans requête gardent la
|
|
résolution par configuration (étapes
|
|
1, 3 et 4), inchangée sinon.
|
|
No public dracodev name is hard-coded
|
|
outside these defaults: dev/prod differ
|
|
only by environment, never by code.
|
|
AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
|
|
events it emits to that journal (the
|
|
service knows it as the "kanbante"
|
|
entry of AGENT_EVENEMENTS_CLES).
|
|
Empty (default) = emission disabled.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
_version_file = next((Path(p) for p in ("/VERSION", BASE_DIR / "VERSION") if Path(p).exists()), None)
|
|
APP_VERSION = _version_file.read_text().strip() if _version_file else "inconnue"
|
|
|
|
|
|
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
|
|
|
|
|
|
# ── Résolution d'environnement des URL publiques dracodev ──
|
|
# 0.32.1 : sur la dev, seul AGENT_EVENEMENTS_URL est posé (hôte
|
|
# agent-dev.dracodev.net) : les URL sœurs retombaient donc sur leurs
|
|
# défauts prod dans la barre commune. La dérivation depuis l'hôte de
|
|
# l'agent évite de devoir déclarer quatre variables de plus.
|
|
# 0.32.2 — couche fautive corrigée : cette dérivation ne couvrait
|
|
# que les URL sœurs, jamais ``agent_url`` lui-même — le panneau
|
|
# recevait donc le défaut prod (agent injoignable, la prod n'étant
|
|
# pas installée) et la console était une page morte sur la dev.
|
|
# Et aucune résolution ne tenait compte de la requête : un hôte
|
|
# public en ``-dev`` sans aucune variable retombait sur la prod.
|
|
# Désormais, pour tout ce qui est rendu à une requête (liens de la
|
|
# barre / DRACO_URLS, source de l'iframe du panneau, origine agent
|
|
# de la CSP) : 1) variable explicite ; 2) hôte public de la
|
|
# requête (motif fermé ci-dessous) ; 3) dérivation par les adresses
|
|
# de l'agent connues en configuration — URL d'événements OU base
|
|
# du service (élargie en 0.32.2) ; 4) défaut officiel.
|
|
_MOTIF_HOTE_AGENT = re.compile(r"agent([a-z0-9-]*)\.dracodev\.net")
|
|
_MOTIF_HOTE_PUBLIC = re.compile(
|
|
r"(?:og|flowdeck|tiktak|kanbante|shaarit|agent)([a-z0-9-]*)\.dracodev\.net"
|
|
)
|
|
|
|
|
|
def _suffixe_depuis_hote(hote: str, motif: re.Pattern) -> str | None:
|
|
"""Suffixe d'environnement porté par un hôte nu, ou ``None``.
|
|
|
|
Retourne le suffixe (``""`` pour le nom officiel, ``"-dev"``
|
|
pour la dev) si l'hôte suit le motif fermé ; ``None`` hors
|
|
motif (autre domaine, LAN, IP…) — aucune dérivation alors,
|
|
jamais de suffixe inventé depuis un hôte arbitraire.
|
|
"""
|
|
correspondance = motif.fullmatch(hote.strip().lower())
|
|
return correspondance.group(1) if correspondance else None
|
|
|
|
|
|
def _suffixe_environnement_agent(url_agent: str) -> str | None:
|
|
"""Suffixe lu sur l'hôte d'une adresse connue de l'agent.
|
|
|
|
Vaut pour l'URL d'événements comme pour la base du service :
|
|
si son hôte est ``agent<SUFFIXE>.dracodev.net``, le suffixe
|
|
est retourné ; ``None`` hors motif.
|
|
"""
|
|
try:
|
|
hote = urlparse(url_agent).hostname or ""
|
|
except ValueError:
|
|
return None
|
|
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_AGENT)
|
|
|
|
|
|
def suffixe_environnement_requete(hote_requete: str | None) -> str | None:
|
|
"""Suffixe lu sur l'hôte public de la requête entrante.
|
|
|
|
Motif fermé ``^(og|flowdeck|tiktak|kanbante|shaarit|agent)<SUFFIXE>
|
|
\\.dracodev\\.net$`` (suffixe optionnel : vide en prod,
|
|
``-dev`` en dev) ; un port éventuel est toléré et retiré.
|
|
Hors motif (LAN, IP, autre domaine) : ``None`` — l'appelant
|
|
retombe alors sur la configuration, jamais sur un suffixe
|
|
deviné.
|
|
"""
|
|
if not hote_requete:
|
|
return None
|
|
hote = hote_requete.strip().lower()
|
|
if hote.startswith("["): # littéral IPv6 : jamais dans le motif
|
|
return None
|
|
hote = hote.split(":", 1)[0]
|
|
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_PUBLIC)
|
|
|
|
|
|
def _url_soeur_draco(
|
|
nom_env: str, slug: str, defaut: str, suffixe: str | None
|
|
) -> str:
|
|
"""Résout l'URL publique d'une app sœur de la barre commune.
|
|
|
|
1) ``nom_env`` explicite — prioritaire, contrat inchangé ;
|
|
2) dérivation d'environnement : ``https://<slug><suffixe>.
|
|
dracodev.net`` quand l'hôte de l'agent suit le motif ;
|
|
3) défaut officiel (prod), comme avant.
|
|
"""
|
|
explicite = os.getenv(nom_env)
|
|
if explicite:
|
|
return explicite.rstrip("/")
|
|
if suffixe is not None:
|
|
return f"https://{slug}{suffixe}.dracodev.net"
|
|
return defaut
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UrlsDraco:
|
|
"""URL publiques dracodev résolues pour un rendu ou un contexte."""
|
|
|
|
sso_url: str # ObsiGate (lien de barre ; explicite = DRACO_SSO_URL)
|
|
flowdeck_url: str
|
|
tiktak_url: str
|
|
shaarit_url: str # ShaarIt (barre v0.2.0 ; explicite = DRACO_SHAARIT_URL)
|
|
public_url: str # Kanbante elle-même (KANBANTE_PUBLIC_URL)
|
|
agent_url: str # base du service d'agents (panneau + CSP)
|
|
|
|
|
|
def resoudre_urls_draco(
|
|
hote_requete: str | None = None, base: "Settings | None" = None
|
|
) -> UrlsDraco:
|
|
"""Résout les URL publiques pour un rendu à une requête.
|
|
|
|
Ordre, par URL : 1) la variable explicite de l'installation —
|
|
prioritaire, inchangée (``DRACO_SSO_URL`` pour ObsiGate,
|
|
``KANBANTE_PUBLIC_URL`` pour Kanbante elle-même, ``AGENT_URL``
|
|
pour l'agent) ; 2) l'hôte public de la requête, s'il suit le
|
|
motif fermé dracodev : son suffixe d'environnement (``-dev``,
|
|
vide en prod) s'applique à toutes les sœurs et à l'agent ;
|
|
3) à défaut, la valeur de configuration ``base`` (défaut :
|
|
``_env_settings()``), qui porte déjà la dérivation par les
|
|
adresses de l'agent connues (événements ou base du service)
|
|
puis le défaut officiel — c'est aussi la résolution des
|
|
contextes sans requête, inchangée.
|
|
"""
|
|
if base is None:
|
|
base = _env_settings()
|
|
suffixe_hote = suffixe_environnement_requete(hote_requete)
|
|
|
|
def resoudre(nom_env: str, slug: str, valeur_base: str) -> str:
|
|
if os.getenv(nom_env):
|
|
return valeur_base # explicite : déjà résolue dans base
|
|
if suffixe_hote is not None:
|
|
return f"https://{slug}{suffixe_hote}.dracodev.net"
|
|
return valeur_base
|
|
|
|
return UrlsDraco(
|
|
sso_url=resoudre("DRACO_SSO_URL", "og", base.sso_url),
|
|
flowdeck_url=resoudre(
|
|
"DRACO_FLOWDECK_URL", "flowdeck", base.flowdeck_url
|
|
),
|
|
tiktak_url=resoudre("DRACO_TIKTAK_URL", "tiktak", base.tiktak_url),
|
|
shaarit_url=resoudre(
|
|
"DRACO_SHAARIT_URL", "shaarit", base.shaarit_url
|
|
),
|
|
public_url=resoudre(
|
|
"KANBANTE_PUBLIC_URL", "kanbante", base.public_url
|
|
),
|
|
agent_url=resoudre("AGENT_URL", "agent", base.agent_url),
|
|
)
|
|
|
|
|
|
@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"
|
|
public_url: str = "https://kanbante.dracodev.net"
|
|
flowdeck_url: str = "https://flowdeck.dracodev.net"
|
|
tiktak_url: str = "https://tiktak.dracodev.net"
|
|
shaarit_url: str = "https://shaarit.dracodev.net"
|
|
agent_url: str = "https://agent.dracodev.net"
|
|
archive_suggest_days: int = 180
|
|
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
|
|
agent_evenements_cle: str = ""
|
|
|
|
@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."""
|
|
agent_url_explicite = os.getenv("AGENT_URL")
|
|
agent_url = (
|
|
agent_url_explicite or "https://agent.dracodev.net"
|
|
).rstrip("/")
|
|
agent_evenements_url = (
|
|
os.getenv("AGENT_EVENEMENTS_URL")
|
|
or f"{agent_url}/api/v1/evenements"
|
|
).rstrip("/")
|
|
# Origines de l'agent telles que l'app les connaît déjà : elles
|
|
# portent le suffixe d'environnement (-dev…) repris par les URL
|
|
# sœurs dépourvues de variable explicite. Dérivation élargie
|
|
# (0.32.2) : l'URL d'événements d'abord, puis la base du service
|
|
# elle-même — selon ce que l'installation déclare.
|
|
suffixe_agent = _suffixe_environnement_agent(agent_evenements_url)
|
|
if suffixe_agent is None:
|
|
suffixe_agent = _suffixe_environnement_agent(agent_url)
|
|
# Base du service dérivée à son tour (0.32.2) : sans AGENT_URL
|
|
# explicite, si l'adresse connue de l'agent est en ``-dev``, le
|
|
# panneau et la console doivent viser cette même origine —
|
|
# auparavant agent_url restait au défaut prod, d'où la console
|
|
# morte sur la dev alors que le journal était bien en dev.
|
|
if not agent_url_explicite and suffixe_agent is not None:
|
|
agent_url = f"https://agent{suffixe_agent}.dracodev.net"
|
|
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=_url_soeur_draco(
|
|
"DRACO_SSO_URL", "og", "https://og.dracodev.net", suffixe_agent
|
|
),
|
|
public_url=_url_soeur_draco(
|
|
"KANBANTE_PUBLIC_URL",
|
|
"kanbante",
|
|
"https://kanbante.dracodev.net",
|
|
suffixe_agent,
|
|
),
|
|
flowdeck_url=_url_soeur_draco(
|
|
"DRACO_FLOWDECK_URL",
|
|
"flowdeck",
|
|
"https://flowdeck.dracodev.net",
|
|
suffixe_agent,
|
|
),
|
|
tiktak_url=_url_soeur_draco(
|
|
"DRACO_TIKTAK_URL",
|
|
"tiktak",
|
|
"https://tiktak.dracodev.net",
|
|
suffixe_agent,
|
|
),
|
|
shaarit_url=_url_soeur_draco(
|
|
"DRACO_SHAARIT_URL",
|
|
"shaarit",
|
|
"https://shaarit.dracodev.net",
|
|
suffixe_agent,
|
|
),
|
|
agent_url=agent_url,
|
|
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
|
|
agent_evenements_url=agent_evenements_url,
|
|
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
|
|
)
|
|
|
|
|
|
# ── 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
|