Files
kanbante/app/board_columns.py
T
Spark 28322c12cf
CI / test (push) Successful in 2m17s
feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
- Table board_columns par espace (clé immuable, nom/couleur/
  ordre libres, étiquette mappée + alias, drapeau Focus) ;
  définitions semées paresseusement depuis les valeurs
  historiques — sans personnalisation, comportement identique.
- Service app/board_columns.py : résolution, mapping
  étiquette→colonne (alias compris, ex. kanban_a_faire de
  Flowdeck), colonnes Focus/Wallboard.
- Board, API, adoption, miroirs d'étiquettes, déplacements,
  capture, santé, focus, wall, dashboard branchés sur les
  colonnes de l'espace ; validations dynamiques dans models
  (move_issue, set_column_order) et rules.
- Administration → Colonnes : ajouter (avant Terminé),
  renommer, réordonner, mapper, supprimer avec migration des
  cartes vers une colonne choisie, réinitialiser ; bouton
  🧱 Colonnes sur le board ; journal d'audit.
- Backlog et Terminé : colonnes système non supprimables.
- 254 tests verts (7 nouveaux, palier 20) ; smoke réel contre
  le vrai Gitea : création, affichage au board, suppression.
2026-10-23 08:38:02 -04:00

137 lines
4.7 KiB
Python

"""Colonnes de board personnalisables par espace (0.30.0).
Avant : six colonnes codées en dur (backlog, todo, in_progress,
qa, review, done) et un mapping fixe colonne → étiquette
``kanban:<colonne>``. Maintenant, chaque espace de travail
définit ses colonnes : nom d'affichage, couleur, ordre,
étiquette de forge mappée et alias reconnus. Les clés sont
immuables (les données board_state les référencent) ; backlog
et done restent des colonnes système (renommables, non
supprimables) parce qu'elles portent une sémantique : défaut
des cartes neuves, et état fermé des issues.
Sans définition propre, un espace retombe sur les défauts
historiques — comportement 0.24 strictement conservé.
"""
from __future__ import annotations
from app import models
DEFAULT_COLUMNS: list[dict] = [
{"key": "backlog", "name": "📥 Backlog", "color": "#6b7280",
"label": "", "aliases": "", "kind": "backlog", "in_focus": 0},
{"key": "todo", "name": "📋 À faire", "color": "#3b82f6",
"label": "kanban:todo", "aliases": "", "kind": "flow",
"in_focus": 0},
{"key": "in_progress", "name": "🚧 En cours", "color": "#f59e0b",
"label": "kanban:in_progress", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "qa", "name": "🧪 QA", "color": "#06b6d4",
"label": "kanban:qa", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "review", "name": "🔍 Révision", "color": "#8b5cf6",
"label": "kanban:review", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "done", "name": "✅ Terminé", "color": "#10b981",
"label": "", "aliases": "", "kind": "done", "in_focus": 0},
]
_cache: dict[int, list[dict]] = {}
def invalidate(workspace_id: int | None = None) -> None:
if workspace_id is None:
_cache.clear()
else:
_cache.pop(workspace_id, None)
def ensure_columns(workspace_id: int) -> list[dict]:
"""Seed the historical defaults if the workspace has none."""
existing = models.list_board_columns(workspace_id)
if existing:
return existing
for position, column in enumerate(DEFAULT_COLUMNS):
models.upsert_board_column(
workspace_id, {**column, "position": position}
)
invalidate(workspace_id)
return models.list_board_columns(workspace_id)
def get_columns(workspace_id: int = 1) -> list[dict]:
"""Effective columns of a workspace (cached, seeded lazily)."""
if workspace_id not in _cache:
_cache[workspace_id] = ensure_columns(workspace_id)
return _cache[workspace_id]
def keys(workspace_id: int = 1) -> list[str]:
return [c["key"] for c in get_columns(workspace_id)]
def names(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["name"] for c in get_columns(workspace_id)}
def colors(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["color"] for c in get_columns(workspace_id)}
def flow_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c["kind"] == "flow"
]
def focus_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c.get("in_focus")
]
def wall_keys(workspace_id: int = 1) -> list[str]:
"""Wallboard cards: focus columns + the first flow column."""
columns = get_columns(workspace_id)
wanted = {c["key"] for c in columns if c.get("in_focus")}
flow = [c["key"] for c in columns if c["kind"] == "flow"]
if flow:
wanted.add(flow[0])
return [c["key"] for c in columns if c["key"] in wanted]
def label_for(workspace_id: int, key: str) -> str | None:
"""The forge label mirrored when a card enters this column."""
for column in get_columns(workspace_id):
if column["key"] == key and column["kind"] == "flow":
return column.get("label") or None
return None
def label_lookup(workspace_id: int = 1) -> dict[str, str]:
"""label name (lowercased) → column key, aliases included.
This is the configurable label/column mapping: the canonical
label of each flow column plus every alias declared for it
(e.g. Flowdeck's ``kanban_a_faire`` on the todo column).
"""
lookup: dict[str, str] = {}
for column in get_columns(workspace_id):
if column["kind"] != "flow":
continue
names = [column.get("label") or ""]
names += [
a.strip() for a in (column.get("aliases") or "").split(",")
]
for name in names:
name = name.strip().lower()
if name:
lookup[name] = column["key"]
return lookup
def recognized_labels(workspace_id: int = 1) -> set[str]:
"""Every label name (lowercased) owned by the mapping."""
return set(label_lookup(workspace_id))