feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
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.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user