CI / test (push) Successful in 2m17s
- 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.
137 lines
4.7 KiB
Python
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))
|