feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
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:
Spark
2026-10-23 08:38:02 -04:00
parent 3964323abd
commit 28322c12cf
13 changed files with 1073 additions and 83 deletions
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## 0.30.0 — 2026-10-23
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
Demande de Bruno : pouvoir personnaliser, retirer et ajouter
des colonnes (Backlog, À faire, En cours, …) et configurer le
mapping label/colonne depuis l'interface web.
- **Colonnes par espace** (Administration → Colonnes, ou le
bouton 🧱 Colonnes sur un board) : ajouter une colonne
(insérée avant Terminé), la renommer (le nom affiché est
libre, émojis compris), changer sa couleur, la monter ou la
descendre, la supprimer — ses cartes sont alors déplacées
vers une colonne de destination choisie. Réinitialisation
aux six colonnes historiques en un clic. Chaque espace a SES
colonnes.
- **Clés immuables** : une colonne garde sa clé interne
(`en_developpement`) quoi qu'on renomme — les cartes, les
limites WIP et les règles y restent rattachés. Backlog et
Terminé sont des colonnes système (renommables, non
supprimables) : défaut des cartes neuves, et issues fermées.
- **Mapping étiquette ↔ colonne configurable** : chaque
colonne de flux a son **étiquette mappée** (défaut
`kanban:<clé>`) posée sur l'issue à chaque déplacement
(miroir), plus des **alias** reconnus à l'adoption — par
exemple déclarer `kanban_a_faire` (convention Flowdeck) en
alias de la colonne À faire fait adopter cette colonne par
le board. Adoption : exactement une étiquette reconnue,
comme avant.
- **Focus / Wallboard** : chaque colonne déclare si elle y
figure (case « Dans Focus / Wallboard ») ; les nouvelles
colonnes y sont par défaut.
- Sans personnalisation, tout est strictement identique à
avant (définitions semées paresseusement depuis les valeurs
historiques). Le triage au clavier garde ses touches
historiques (1 = À faire, 2 = En cours, …).
## 0.29.0 — 2026-10-23
### Modifié — panneau Spark V3 : la console en surimpression pleine page
+136
View File
@@ -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))
+1 -1
View File
@@ -113,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.29.0"
APP_VERSION = "0.30.0"
def _env_bool(name: str, default: bool) -> bool:
+1 -1
View File
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.24.0"},
"clientInfo": {"name": "kanbante", "version": "0.30.0"},
},
req_id=1,
)
+376 -71
View File
@@ -51,6 +51,7 @@ from app import forecast as forecast_service
from app import rules as rules_service
from app import sprint as sprint_service
from app import connections as connections_service
from app import board_columns as bcols
from app.briefing import (
format_briefing_text,
format_standup_text,
@@ -59,6 +60,7 @@ from app.briefing import (
)
from app.capture import ntfy_capture_loop, parse_capture_text
from app import agent_journal
from app import board_columns as bcols
from app import models
from app.models import (
KANBAN_COLUMNS,
@@ -814,7 +816,10 @@ async def archives_export(
]
for column in KANBAN_COLUMNS:
cards = board.get(column) or []
lines.append(f"### {COLUMN_LABELS[column]} ({len(cards)})")
lines.append(
f"### {bcols.names(workspace['id']).get(column, column)} "
f"({len(cards)})"
)
for card in cards:
lines.append(f"- #{card['issue_number']}")
lines.append("")
@@ -853,6 +858,22 @@ async def archives_export(
WORKSPACE_COOKIE = "kanbante_workspace"
def _ws_names(request: Request) -> dict[str, str]:
"""Display names of the current workspace's columns (0.30.0)."""
workspace, _role = _current_workspace(request)
return bcols.names(workspace["id"] if workspace else 1)
def _ws_colors(request: Request) -> dict[str, str]:
workspace, _role = _current_workspace(request)
return bcols.colors(workspace["id"] if workspace else 1)
def _ws_keys(request: Request) -> list[str]:
workspace, _role = _current_workspace(request)
return bcols.keys(workspace["id"] if workspace else 1)
def _workspace_role_for(workspace_id: int, user: dict | None) -> str | None:
if user is None or user.get("id") is None:
return "admin" # mode env / auth désactivée : accès complet
@@ -1495,6 +1516,259 @@ async def admin_workspace_delete(
)
# ── Administration : colonnes du board (0.30.0) ───────────
def _column_key_from_name(name: str, taken: set[str]) -> str:
import unicodedata
folded = unicodedata.normalize("NFKD", name or "")
folded = folded.encode("ascii", "ignore").decode("ascii")
base = re.sub(r"[^a-z0-9]+", "_", folded.lower()).strip("_")
base = base or "colonne"
key, n = base, 2
while key in taken:
key = f"{base}_{n}"
n += 1
return key
def _renumber_columns(workspace_id: int) -> None:
for position, column in enumerate(
models.list_board_columns(workspace_id)
):
if column["position"] != position:
models.upsert_board_column(
workspace_id, {**column, "position": position}
)
bcols.invalidate(workspace_id)
@app.get("/admin/columns")
async def admin_columns(request: Request, msg: str = "", err: str = ""):
workspace, _role = _current_workspace(request)
if workspace is None or (
_can_admin_workspace(request, workspace["id"]) is None
):
return _admin_denied(request)
columns = bcols.get_columns(workspace["id"])
counts = {
c["key"]: models.count_column_cards(workspace["id"], c["key"])
for c in columns
}
return render(
request, "admin_columns.html",
columns=columns, counts=counts, workspace=workspace,
msg=msg, err=err,
)
@app.post("/admin/columns")
async def admin_column_create(
request: Request,
name: str = Form(""),
color: str = Form("#7c5cfc"),
label: str = Form(""),
aliases: str = Form(""),
csrf_token: str = Form(""),
):
workspace, _role = _current_workspace(request)
if workspace is None:
return _admin_denied(request)
admin = _can_admin_workspace(request, workspace["id"])
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
name = name.strip()
if not name:
return RedirectResponse(
"/admin/columns?err=Nom+requis", status_code=303
)
ws_id = workspace["id"]
columns = bcols.get_columns(ws_id)
key = _column_key_from_name(name, {c["key"] for c in columns})
# Insérée juste avant la colonne « done ».
done_pos = next(
(c["position"] for c in columns if c["kind"] == "done"),
len(columns),
)
for column in columns:
if column["position"] >= done_pos:
models.upsert_board_column(
ws_id, {**column, "position": column["position"] + 1}
)
models.upsert_board_column(
ws_id,
{
"key": key, "name": name,
"color": color if color.startswith("#") else "#7c5cfc",
"position": done_pos,
"label": label.strip() or f"kanban:{key}",
"aliases": aliases.strip(),
"kind": "flow", "in_focus": 1,
},
)
_renumber_columns(ws_id)
models.log_admin(
admin.get("id"), "column.create", f"{key} (espace {workspace['slug']})"
)
return RedirectResponse(
"/admin/columns?msg=Colonne+ajout%C3%A9e", status_code=303
)
@app.post("/admin/columns/reset")
async def admin_columns_reset(
request: Request, csrf_token: str = Form("")
):
workspace, _role = _current_workspace(request)
if workspace is None:
return _admin_denied(request)
admin = _can_admin_workspace(request, workspace["id"])
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
models.clear_board_columns(workspace["id"])
bcols.invalidate(workspace["id"])
models.log_admin(
admin.get("id"), "column.reset", f"espace {workspace['slug']}"
)
return RedirectResponse(
"/admin/columns?msg=Colonnes+r%C3%A9initialis%C3%A9es+aux+valeurs+par+d%C3%A9faut",
status_code=303,
)
@app.post("/admin/columns/{key}")
async def admin_column_update(
request: Request,
key: str,
name: str = Form(""),
color: str = Form(""),
label: str = Form(""),
aliases: str = Form(""),
in_focus: str = Form(""),
csrf_token: str = Form(""),
):
workspace, _role = _current_workspace(request)
if workspace is None:
return _admin_denied(request)
admin = _can_admin_workspace(request, workspace["id"])
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
ws_id = workspace["id"]
current = next(
(c for c in bcols.get_columns(ws_id) if c["key"] == key), None
)
if current is None:
return RedirectResponse(
"/admin/columns?err=Colonne+inconnue", status_code=303
)
updated = dict(current)
if name.strip():
updated["name"] = name.strip()
if color.startswith("#"):
updated["color"] = color
if current["kind"] == "flow":
updated["label"] = label.strip()
updated["aliases"] = aliases.strip()
updated["in_focus"] = 1 if in_focus else 0
models.upsert_board_column(ws_id, updated)
bcols.invalidate(ws_id)
models.log_admin(
admin.get("id"), "column.update", f"{key} (espace {workspace['slug']})"
)
return RedirectResponse(
"/admin/columns?msg=Colonne+mise+%C3%A0+jour", status_code=303
)
@app.post("/admin/columns/{key}/move")
async def admin_column_move(
request: Request,
key: str,
direction: str = Form("up"),
csrf_token: str = Form(""),
):
workspace, _role = _current_workspace(request)
if workspace is None:
return _admin_denied(request)
admin = _can_admin_workspace(request, workspace["id"])
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
ws_id = workspace["id"]
columns = bcols.get_columns(ws_id)
index = next(
(i for i, c in enumerate(columns) if c["key"] == key), None
)
if index is None:
return RedirectResponse("/admin/columns", status_code=303)
swap = index - 1 if direction == "up" else index + 1
if 0 <= swap < len(columns):
other = columns[swap]
mine = columns[index]
models.upsert_board_column(
ws_id, {**mine, "position": other["position"]}
)
models.upsert_board_column(
ws_id, {**other, "position": mine["position"]}
)
_renumber_columns(ws_id)
models.log_admin(
admin.get("id"), "column.move",
f"{key} {direction} (espace {workspace['slug']})",
)
return RedirectResponse("/admin/columns", status_code=303)
@app.post("/admin/columns/{key}/delete")
async def admin_column_delete(
request: Request,
key: str,
target: str = Form(""),
csrf_token: str = Form(""),
):
workspace, _role = _current_workspace(request)
if workspace is None:
return _admin_denied(request)
admin = _can_admin_workspace(request, workspace["id"])
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
ws_id = workspace["id"]
columns = bcols.get_columns(ws_id)
current = next((c for c in columns if c["key"] == key), None)
if current is None or current["kind"] != "flow":
return RedirectResponse(
"/admin/columns?err=Colonne+syst%C3%A8me+non+supprimable",
status_code=303,
)
if target not in {c["key"] for c in columns} or target == key:
return RedirectResponse(
"/admin/columns?err=Choisis+une+colonne+de+destination",
status_code=303,
)
moved = models.migrate_column_cards(ws_id, key, target)
models.delete_board_column(ws_id, key)
_renumber_columns(ws_id)
models.log_admin(
admin.get("id"), "column.delete",
f"{key} -> {target} ({moved} cartes, espace {workspace['slug']})",
)
from urllib.parse import quote as _q
return RedirectResponse(
f"/admin/columns?msg={_q(f'Colonne supprimée, {moved} carte(s) déplacée(s)')}",
status_code=303,
)
@app.get("/admin/settings")
async def admin_settings(request: Request, msg: str = "", err: str = ""):
admin = _require_admin(request)
@@ -1849,6 +2123,7 @@ _NAV_BY_TEMPLATE = {
"admin.html": "admin",
"admin_connections.html": "admin_connections",
"admin_workspaces.html": "admin_workspaces",
"admin_columns.html": "admin_columns",
"admin_settings.html": "admin_settings",
"archives.html": "archives",
}
@@ -2003,7 +2278,9 @@ def _is_issue(item: dict) -> bool:
def _merge_issues_with_board(
issues: list[dict], board: dict[str, list[dict]]
issues: list[dict],
board: dict[str, list[dict]],
column_keys: list[str] | None = None,
) -> dict[str, list[dict]]:
"""Merge Gitea issues into kanban columns, honouring saved order."""
placement: dict[int, tuple[str, float]] = {}
@@ -2011,9 +2288,13 @@ def _merge_issues_with_board(
for card in cards:
placement[card["issue_number"]] = (col_name, card["position"])
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
columns: dict[str, list[dict]] = {
col: [] for col in (column_keys or KANBAN_COLUMNS)
}
for index, issue in enumerate(issues):
col, pos = placement.get(issue["number"], ("backlog", None))
if col not in columns:
col, pos = "backlog", None
issue["_sort"] = (0, pos, index) if pos is not None else (1, 0.0, index)
columns[col].append(issue)
for col in columns.values():
@@ -2075,16 +2356,15 @@ def _adopt_label_columns(
for col_name, cards in board_state.items():
for card in cards:
placement[card["issue_number"]] = col_name
lookup = bcols.label_lookup(workspace_id)
for issue in open_issues:
names = [
str(label.get("name", "")) for label in issue.get("labels") or []
]
kanban = [n for n in names if n.startswith("kanban:")]
if len(kanban) != 1:
continue
target = kanban[0].split(":", 1)[1]
if target not in LABELLED_COLUMNS:
recognized = [lookup[n.lower()] for n in names if n.lower() in lookup]
if len(recognized) != 1:
continue
target = recognized[0]
current = placement.get(issue["number"], "backlog")
if current != target:
move_issue(
@@ -2288,7 +2568,7 @@ def _health_dot(health: dict) -> dict:
parts.append(f"{len(zombies)} zombie(s)")
if slowest:
parts.append(
f"goulot : {COLUMN_LABELS[slowest['column']]}"
f"goulot : {bcols.names(health.get('_ws_id', 1)).get(slowest['column'], slowest['column'])}"
f" (médiane {median} j)"
)
return {
@@ -2382,8 +2662,9 @@ async def dashboard(request: Request):
inactive_listed=[r for r in inactive if not r.get("starred")],
pinned_repos=pinned,
stats=get_stats(),
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
COLUMN_LABELS=bcols.names(ws_id),
COLUMN_COLORS=bcols.colors(ws_id),
column_keys=bcols.keys(ws_id),
active_window_days=get_settings().active_window_days,
metrics=get_metrics(window_days=30),
multi_conn=len(models.workspace_connection_ids(ws_id)) > 1,
@@ -2516,7 +2797,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
board_state = get_board_state(
full_name, workspace_id=ws_id, connection_id=conn_id
)
columns = _merge_issues_with_board(display_issues, board_state)
columns = _merge_issues_with_board(
display_issues, board_state, bcols.keys(ws_id)
)
await _annotate_code(owner, repo, columns, client)
labels: dict[str, dict] = {}
@@ -2538,8 +2821,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
repo=repo,
full_name=full_name,
columns=columns,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
column_keys=bcols.keys(ws_id),
column_labels=bcols.names(ws_id),
column_colors=bcols.colors(ws_id),
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
notes=get_notes(
full_name, workspace_id=ws_id, connection_id=conn_id
@@ -2613,7 +2897,9 @@ def _board_columns_for(
)
display_issues = [i for i in display_issues if _in_ms(i)]
columns = _merge_issues_with_board(display_issues, board_state)
columns = _merge_issues_with_board(
display_issues, board_state, bcols.keys(workspace_id)
)
return columns, display_issues, board_state
@@ -2642,7 +2928,7 @@ async def api_board(
)
card_template = _tpl_env.get_template("_card.html")
payload: dict[str, dict] = {}
for col in KANBAN_COLUMNS:
for col in bcols.keys(ws_id):
cards = columns[col]
if cards:
html = "".join(
@@ -2738,10 +3024,11 @@ async def _collect_focus_groups(
get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
),
bcols.keys(workspace_id),
)
cards = [
{"issue": issue, "column": col}
for col in FOCUS_COLUMNS
for col in bcols.focus_keys(workspace_id)
for issue in columns[col]
]
if cards:
@@ -2861,8 +3148,8 @@ async def project_page(request: Request, owner: str, repo: str):
events=events,
notes=get_notes(full_name, workspace_id=ws_id, connection_id=conn_id),
EVENT_ICONS=EVENT_ICONS,
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
COLUMN_LABELS=bcols.names(ws_id),
COLUMN_COLORS=bcols.colors(ws_id),
)
@@ -2932,7 +3219,7 @@ async def triage_session(request: Request, owner: str, repo: str):
full_name=full_name,
queue=queue,
queue_json=json.dumps(queue, ensure_ascii=False),
column_labels=COLUMN_LABELS,
column_labels=bcols.names(ws_id),
)
@@ -2969,7 +3256,7 @@ async def _enrich_pick(
"title": issue.get("title", ""),
"state": issue.get("state", "open"),
"column": column,
"column_label": COLUMN_LABELS[column],
"column_label": bcols.names(workspace_id).get(column, column),
"html_url": issue.get("html_url", ""),
}
@@ -3076,7 +3363,9 @@ async def today_page(request: Request):
"repo": group["full_name"],
"number": card["issue"]["number"],
"title": card["issue"].get("title", ""),
"column_label": COLUMN_LABELS[card["column"]],
"column_label": bcols.names(ws_id).get(
card["column"], card["column"]
),
}
)
candidates = candidates[:8]
@@ -3096,12 +3385,12 @@ async def today_page(request: Request):
carry_over=carry_over,
candidates=candidates,
recap=recap,
column_labels=COLUMN_LABELS,
column_labels=bcols.names(ws_id),
)
async def _sprint_markdown(
owner: str, repo: str, milestone: str, client=None
owner: str, repo: str, milestone: str, client=None, workspace_id: int = 1
):
"""Assemble the sprint story; (title, markdown) or None."""
full_name = f"{owner}/{repo}"
@@ -3188,7 +3477,7 @@ async def _sprint_markdown(
{
"number": issue["number"],
"title": issue.get("title", ""),
"column_label": COLUMN_LABELS[col],
"column_label": bcols.names(workspace_id).get(col, col),
}
)
delivered.sort(key=lambda d: d["number"])
@@ -3233,7 +3522,9 @@ async def sprint_page(request: Request, owner: str, repo: str, milestone: str =
full_name = f"{owner}/{repo}"
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
result = (
await _sprint_markdown(owner, repo, milestone, client)
await _sprint_markdown(
owner, repo, milestone, client, workspace_id=ws_id
)
if milestone
else None
)
@@ -3290,7 +3581,7 @@ async def _apply_rule_actions(
text = (
f"⚙️ Règle « {rule['name']} »\n"
f"{full_name} #{number} « {issue.get('title', '')} » — "
f"{COLUMN_LABELS[column]}{age_txt}"
f"{bcols.names(1).get(column, column)}{age_txt}"
)
if await send_ntfy(get_settings(), text):
set_kv(guard, "1")
@@ -3447,20 +3738,23 @@ async def _project_health(
"number": number,
"title": issue.get("title", ""),
"column": column,
"column_label": COLUMN_LABELS[column],
"column_label": bcols.names(workspace_id).get(
column, column
),
"days_inactive": days,
"html_url": issue.get("html_url", ""),
}
)
zombies.sort(key=lambda z: -z["days_inactive"])
return {
"_ws_id": workspace_id,
"full_name": full_name,
"dwell": dwell,
"slowest": slowest,
"zombies": zombies,
"zombie_days": settings.zombie_days,
"open_count": len(display_issues),
"column_labels": COLUMN_LABELS,
"column_labels": bcols.names(workspace_id),
}
@@ -3696,8 +3990,8 @@ async def focus(request: Request):
request,
"focus.html",
groups=await _collect_focus_groups(ws_repos, ws_id),
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
column_labels=bcols.names(ws_id),
column_colors=bcols.colors(ws_id),
)
@@ -3789,14 +4083,16 @@ async def _collect_wall_data(
board_state = get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
)
columns = _merge_issues_with_board(open_issues, board_state)
columns = _merge_issues_with_board(
open_issues, board_state, bcols.keys(workspace_id)
)
ages = _card_ages(
full_name, open_issues, board_state,
workspace_id=workspace_id, connection_id=cid,
)
aging_days = get_settings().aging_days
cards = []
for col in WALL_COLUMNS:
for col in bcols.wall_keys(workspace_id):
for issue in columns[col]:
age = ages.get(issue["number"]) or {}
days = age.get("days")
@@ -3805,14 +4101,18 @@ async def _collect_wall_data(
"number": issue["number"],
"title": issue.get("title", ""),
"column": col,
"column_label": COLUMN_LABELS[col],
"column_label": bcols.names(workspace_id).get(
col, col
),
"age_days": days,
"aging": days is not None and days >= aging_days,
}
)
return {
"full_name": full_name,
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
"counts": {
col: len(columns[col]) for col in bcols.keys(workspace_id)
},
"open_total": len(open_issues),
"cards": cards[:9],
"cards_total": len(cards),
@@ -3835,9 +4135,9 @@ async def wall(request: Request, repo: str = ""):
"wall.html",
slides=slides,
single=bool(repo),
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
columns=KANBAN_COLUMNS,
column_labels=bcols.names(ws_id),
column_colors=bcols.colors(ws_id),
columns=bcols.keys(ws_id),
)
@@ -3871,8 +4171,8 @@ async def search_page(request: Request, q: str = ""):
q=q,
groups=groups,
total=total,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
column_labels=_ws_names(request),
column_colors=_ws_colors(request),
)
@@ -3916,8 +4216,8 @@ async def my_issues(request: Request):
user=user,
groups=groups,
total=total,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
column_labels=_ws_names(request),
column_colors=_ws_colors(request),
)
@@ -3952,9 +4252,9 @@ async def _briefing_data() -> dict:
"number": issue["number"],
"title": issue["title"],
"days": ages[issue["number"]]["days"],
"column_label": COLUMN_LABELS[
column_of.get(issue["number"], "backlog")
],
"column_label": bcols.names(1).get(
column_of.get(issue["number"], "backlog"), "backlog"
),
}
for issue in open_issues
if issue["number"] in ages
@@ -4439,14 +4739,11 @@ async def _do_capture(
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
if column not in KANBAN_COLUMNS:
raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
client, conn_id = await _client_for_repo(repo_full_name)
issue = await client.create_issue(owner, repo, title, body or "")
# La carte vit dans le premier espace qui détient la connexion.
ws_id = next(
(
@@ -4456,6 +4753,9 @@ async def _do_capture(
),
1,
)
if column not in bcols.keys(ws_id):
raise ValueError("Invalid column")
issue = await client.create_issue(owner, repo, title, body or "")
move_issue(
repo_full_name, issue["number"], column,
workspace_id=ws_id, connection_id=conn_id,
@@ -4644,7 +4944,9 @@ async def _weekly_digest() -> dict:
"cards": [
{
"issue": card["issue"],
"column": COLUMN_LABELS[card["column"]],
"column": bcols.names(1).get(
card["column"], card["column"]
),
}
for card in group["cards"]
],
@@ -4809,7 +5111,7 @@ async def activity(request: Request):
"activity.html",
events=events,
event_icons=EVENT_ICONS,
column_labels=COLUMN_LABELS,
column_labels=_ws_names(request),
)
@@ -4835,10 +5137,6 @@ async def api_move(
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if to_column not in KANBAN_COLUMNS:
return JSONResponse(
{"ok": False, "error": "Invalid column"}, status_code=400
)
parts = _split_full_name(repo_full_name)
if parts is None:
return JSONResponse(
@@ -4846,12 +5144,16 @@ async def api_move(
)
owner, repo = parts
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
if to_column not in bcols.keys(ws_id):
return JSONResponse(
{"ok": False, "error": "Invalid column"}, status_code=400
)
move_issue(
repo_full_name, issue_number, to_column,
workspace_id=ws_id, connection_id=conn_id,
)
if from_column in KANBAN_COLUMNS and from_column != to_column:
if from_column in bcols.keys(ws_id) and from_column != to_column:
remaining = [
n for n in _parse_order(from_order) if n != issue_number
]
@@ -4917,18 +5219,18 @@ async def api_move(
)
result["gitea_sync"] = "failed"
# Mirror intermediate columns to Gitea labels (kanban:<column>) so
# the workflow state is visible in Gitea itself, not just here.
# Mirror the column on the issue's mapped label (0.30.0:
# configurable per workspace) so the workflow state is
# visible on the forge itself, not just here.
result["label_sync"] = "skipped"
if settings.sync_labels and from_column != to_column:
try:
desired = (
f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
)
desired = bcols.label_for(ws_id, to_column)
recognized = bcols.recognized_labels(ws_id)
issue = await client.get_issue(owner, repo, issue_number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
if str(label.get("name", "")).lower() in recognized
]
for label in current:
if label.get("name") != desired:
@@ -4937,7 +5239,8 @@ async def api_move(
)
if desired and all(l.get("name") != desired for l in current):
label = await client.ensure_label(
owner, repo, desired, COLUMN_COLORS[to_column]
owner, repo, desired,
bcols.colors(ws_id).get(to_column, "#6b7280"),
)
await client.add_issue_label(owner, repo, issue_number, label["id"])
result["label_sync"] = "ok"
@@ -5097,20 +5400,20 @@ _BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
async def _sync_kanban_label(
owner: str, repo: str, number: int, column: str, client=None
owner: str, repo: str, number: int, column: str, client=None,
workspace_id: int = 1,
) -> None:
"""Mirror a column on the issue's kanban:* label (best effort)."""
"""Mirror a column on the issue's mapped label (best effort)."""
if not get_settings().sync_labels:
return
client = client or gitea
try:
desired = (
f"kanban:{column}" if column in LABELLED_COLUMNS else None
)
desired = bcols.label_for(workspace_id, column)
recognized = bcols.recognized_labels(workspace_id)
issue = await client.get_issue(owner, repo, number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
if str(label.get("name", "")).lower() in recognized
]
for label in current:
if label.get("name") != desired:
@@ -5119,7 +5422,8 @@ async def _sync_kanban_label(
)
if desired and all(l.get("name") != desired for l in current):
label = await client.ensure_label(
owner, repo, desired, COLUMN_COLORS[column]
owner, repo, desired,
bcols.colors(workspace_id).get(column, "#6b7280"),
)
await client.add_issue_label(owner, repo, number, label["id"])
except Exception as exc:
@@ -5226,7 +5530,8 @@ async def api_create_branch(
title=issue.get("title", ""),
)
await _sync_kanban_label(
owner, repo, number, "in_progress", client
owner, repo, number, "in_progress", client,
workspace_id=ws_id,
)
moved = True
await _publish_board_changed(full_name)
@@ -5873,7 +6178,7 @@ async def api_palette(q: str = ""):
"title": item.get("title", ""),
"state": item.get("state", "open"),
"column": column,
"column_label": COLUMN_LABELS[column],
"column_label": bcols.names(1).get(column, column),
}
)
if len(issues) >= 8:
+136 -6
View File
@@ -154,6 +154,21 @@ def init_db() -> None:
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Colonnes personnalisables par espace (0.30.0) ──
CREATE TABLE IF NOT EXISTS board_columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL,
key TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
position INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
aliases TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'flow',
in_focus INTEGER NOT NULL DEFAULT 0,
UNIQUE(workspace_id, key)
);
CREATE TABLE IF NOT EXISTS workspace_connections (
workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL,
@@ -219,7 +234,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
# get_board_state() with a KeyError. Drop them once, here.
placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
conn.execute(
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders})",
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders}) "
"AND NOT EXISTS (SELECT 1 FROM board_columns bc "
"WHERE bc.workspace_id = board_state.workspace_id "
"AND bc.key = board_state.column_name)",
KANBAN_COLUMNS,
)
@@ -340,7 +358,9 @@ def get_board_state(
(workspace_id, connection_id, repo_full_name),
).fetchall()
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
columns: dict[str, list[dict]] = {
col: [] for col in column_keys(workspace_id)
}
for row in rows:
column = row["column_name"]
if column not in columns:
@@ -364,7 +384,7 @@ def move_issue(
connection_id: int = 1,
) -> None:
"""Move an issue to a different column (appended at the end)."""
if to_column not in KANBAN_COLUMNS:
if to_column not in column_keys(workspace_id):
raise ValueError(f"Invalid column: {to_column}")
with get_db() as conn:
row = conn.execute(
@@ -400,7 +420,7 @@ def set_column_order(
its index in the list. Used after a drag & drop so manual ordering
survives reloads.
"""
if column_name not in KANBAN_COLUMNS:
if column_name not in column_keys(workspace_id):
raise ValueError(f"Invalid column: {column_name}")
with get_db() as conn:
for index, issue_number in enumerate(ordered_issue_numbers):
@@ -793,11 +813,10 @@ def get_stats() -> dict:
projects: dict[str, dict[str, int]] = {}
for row in rows:
if row["column_name"] not in KANBAN_COLUMNS:
continue
name = row["repo_full_name"]
if name not in projects:
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
# Les colonnes personnalisées (0.30.0) s'ajoutent au vol.
projects[name][row["column_name"]] = row["cnt"]
return projects
@@ -1253,3 +1272,114 @@ def list_admin_audit(limit: int = 100) -> list[dict]:
(limit,),
).fetchall()
return [dict(r) for r in rows]
# ── Colonnes de board personnalisées (0.30.0) ─────────────
# Définitions PAR ESPACE : clé immuable, nom/couleur libres,
# étiquette forge mappée (+ alias reconnus à l'adoption).
# Sans lignes pour un espace, column_keys retombe sur les
# défauts historiques — comportement 0.24 strictement conservé.
def list_board_columns(workspace_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM board_columns WHERE workspace_id = ? "
"ORDER BY position, id",
(workspace_id,),
).fetchall()
return [dict(r) for r in rows]
def column_keys(workspace_id: int = 1) -> list[str]:
"""Ordered column keys of a workspace (defaults if unset)."""
cols = list_board_columns(workspace_id)
if not cols:
return list(KANBAN_COLUMNS)
return [c["key"] for c in cols]
def upsert_board_column(workspace_id: int, column: dict) -> None:
with get_db() as conn:
conn.execute(
"""INSERT INTO board_columns
(workspace_id, key, name, color, position, label,
aliases, kind, in_focus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id, key) DO UPDATE SET
name = excluded.name, color = excluded.color,
position = excluded.position, label = excluded.label,
aliases = excluded.aliases, kind = excluded.kind,
in_focus = excluded.in_focus""",
(
workspace_id, column["key"], column["name"],
column.get("color", "#6b7280"),
int(column.get("position", 0)),
column.get("label", ""), column.get("aliases", ""),
column.get("kind", "flow"),
1 if column.get("in_focus") else 0,
),
)
def delete_board_column(workspace_id: int, key: str) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM board_columns WHERE workspace_id = ? AND key = ?",
(workspace_id, key),
)
def clear_board_columns(workspace_id: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM board_columns WHERE workspace_id = ?",
(workspace_id,),
)
def count_column_cards(workspace_id: int, key: str) -> int:
with get_db() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM board_state "
"WHERE workspace_id = ? AND column_name = ?",
(workspace_id, key),
).fetchone()
return int(row["n"]) if row else 0
def migrate_column_cards(
workspace_id: int, from_key: str, to_key: str
) -> int:
"""Move every card of a column to another (column deletion).
Positions are appended after the target column's current
maximum, per repository and connection.
"""
with get_db() as conn:
rows = conn.execute(
"SELECT connection_id, repo_full_name, issue_number "
"FROM board_state WHERE workspace_id = ? AND column_name = ? "
"ORDER BY position, issue_number",
(workspace_id, from_key),
).fetchall()
for row in rows:
nxt = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 AS p FROM board_state "
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND column_name = ?",
(
workspace_id, row["connection_id"],
row["repo_full_name"], to_key,
),
).fetchone()
conn.execute(
"UPDATE board_state SET column_name = ?, position = ?, "
"updated_at = datetime('now') WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ? "
"AND issue_number = ?",
(
to_key, nxt["p"], workspace_id, row["connection_id"],
row["repo_full_name"], row["issue_number"],
),
)
return len(rows)
+4 -1
View File
@@ -20,6 +20,7 @@ rule can never close an issue or move a card between columns.
import json
from app import models
from app.models import KANBAN_COLUMNS
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
@@ -60,7 +61,9 @@ def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
"(fermer/déplacer une carte par règle est interdit en v1)"
)
continue
if "column" in when and when["column"] not in KANBAN_COLUMNS:
if "column" in when and when["column"] not in (
set(KANBAN_COLUMNS) | set(models.column_keys(1))
):
errors.append(f"{name} : colonne inconnue « {when['column']} »")
continue
if "min_age_days" in when and (
+1
View File
@@ -38,6 +38,7 @@
("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
("admin_columns", "/admin/columns", "🧱", "Colonnes"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]),
] %}
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Colonnes — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🧱 Colonnes du board</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>Comment marche le mapping</h2>
<p class="notes-hint">
<strong>Miroir</strong> : quand une carte entre dans une colonne, son <em>étiquette mappée</em> est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).<br>
<strong>Adoption</strong> : si une issue porte <em>exactement une</em> étiquette reconnue (étiquette mappée ou alias d'une colonne), le board adopte cette colonne — c'est ce qui permet à d'autres outils de piloter le board par étiquettes.<br>
<strong>Alias</strong> : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck <code>kanban_a_faire</code> sur la colonne À faire.<br>
<strong>Focus</strong> : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
</p>
</section>
{% for c in columns %}
<section class="briefing-card">
<h2>
<span class="ws-dot" style="background: {{ c.color }}"></span>
{{ c.name }}
<span class="muted"><code>{{ c.key }}</code> · {{ counts[c.key] }} carte(s) dans cet espace</span>
{% if c.kind == 'backlog' %}<span class="chip">système — défaut des cartes neuves</span>{% endif %}
{% if c.kind == 'done' %}<span class="chip">système — issues fermées</span>{% endif %}
</h2>
<form method="post" action="/admin/columns/{{ c.key }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" value="{{ c.name }}"></label>
<label>Couleur <input type="color" name="color" value="{{ c.color }}"></label>
{% if c.kind == 'flow' %}
<label>Étiquette mappée <input type="text" name="label" value="{{ c.label }}" placeholder="kanban:{{ c.key }}"></label>
<label>Alias (virgules) <input type="text" name="aliases" value="{{ c.aliases }}" placeholder="kanban_a_faire, a-faire"></label>
<label><input type="checkbox" name="in_focus" value="1"{% if c.in_focus %} checked{% endif %}> Dans Focus / Wallboard</label>
{% endif %}
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="admin-actions">
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
</form>
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
</form>
{% if c.kind == 'flow' %}
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Déplacer ses cartes vers
<select name="target">
{% for other in columns %}{% if other.key != c.key %}<option value="{{ other.key }}">{{ other.name }}</option>{% endif %}{% endfor %}
</select>
</label>
<button type="submit" class="btn-mini btn-danger">Supprimer la colonne</button>
</form>
{% endif %}
</p>
</section>
{% endfor %}
<section class="briefing-card">
<h2>➕ Nouvelle colonne</h2>
<form method="post" action="/admin/columns" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
<button type="submit" class="btn-save btn-inline">Ajouter (avant Terminé)</button>
</form>
</section>
<section class="briefing-card">
<h2>↩️ Réinitialiser</h2>
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Réinitialiser les colonnes</button>
</form>
</section>
</main>
</body>
</html>
+2 -1
View File
@@ -53,6 +53,7 @@
</select>
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required>
@@ -62,7 +63,7 @@
<main class="board-container">
<div class="kanban-board">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
{% for col in column_keys %}
<div class="kanban-column"
data-column="{{ col }}">
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
+1 -1
View File
@@ -146,7 +146,7 @@
{% endif %}
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
{% for col in column_keys %}
{% set n = stats[full].get(col, 0) %}
{% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.29.0"
version = "0.30.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+277
View File
@@ -0,0 +1,277 @@
"""Palier 20 (0.30.0): colonnes personnalisables par
espace et mapping étiquette ↔ colonne configurable."""
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import board_columns as bcols
from app import models
from tests.conftest import csrf_from
@pytest.fixture
def auth_client(tmp_path, monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "cols.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
client.get("/login")
token = client.cookies.get("kanbante_csrf") or ""
client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"next": "/",
"csrf_token": token,
},
follow_redirects=False,
)
yield client
@pytest.fixture
def ws_cols():
"""Un espace dédié rattaché à la connexion héritée."""
ws_id = models.create_workspace("cols-test", "ColonnesTest", "#123456")
models.set_workspace_connections(ws_id, [1])
bcols.invalidate(ws_id)
yield ws_id
models.clear_board_columns(ws_id)
bcols.invalidate(ws_id)
models.delete_workspace(ws_id)
def _issue(number, title, labels=None):
return {
"number": number,
"title": title,
"body": "",
"state": "open",
"labels": labels or [],
"assignee": None,
"assignees": [],
"milestone": None,
"due_date": None,
"created_at": "2026-09-01T09:00:00Z",
"updated_at": "2026-09-02T09:00:00Z",
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
"pull_request": None,
}
# ── Défauts et service ────────────────────────────────────
def test_defaults_match_historical_columns(ws_cols):
columns = bcols.get_columns(ws_cols)
assert [c["key"] for c in columns] == [
"backlog", "todo", "in_progress", "qa", "review", "done",
]
assert bcols.names(ws_cols)["todo"] == "📋 À faire"
assert bcols.label_for(ws_cols, "review") == "kanban:review"
assert bcols.label_for(ws_cols, "backlog") is None
assert bcols.focus_keys(ws_cols) == ["in_progress", "qa", "review"]
assert bcols.wall_keys(ws_cols) == [
"todo", "in_progress", "qa", "review",
]
def test_custom_column_at_model_level(ws_cols):
columns = bcols.get_columns(ws_cols)
models.upsert_board_column(
ws_cols,
{
"key": "dev", "name": "🛠️ Dev", "color": "#123123",
"position": 4, "label": "etape:dev", "aliases": "",
"kind": "flow", "in_focus": 1,
},
)
bcols.invalidate(ws_cols)
assert "dev" in models.column_keys(ws_cols)
models.move_issue(
"Projets/demo", 5, "dev", workspace_id=ws_cols, connection_id=1
)
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert [c["issue_number"] for c in state["dev"]] == [5]
assert bcols.label_for(ws_cols, "dev") == "etape:dev"
assert "dev" in bcols.focus_keys(ws_cols)
# Un autre espace n'est pas affecté.
other = models.create_workspace("cols-temoin", "Témoin", "#000000")
try:
assert "dev" not in models.column_keys(other)
finally:
models.delete_workspace(other)
# ── Panneau d'administration ──────────────────────────────
def test_admin_columns_crud(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
page = auth_client.get("/admin/columns")
assert page.status_code == 200
assert "Colonnes du board" in page.text
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/columns",
data={
"name": "🛠️ En développement",
"color": "#224466",
"label": "etape:dev",
"aliases": "en-dev",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
keys = bcols.keys(ws_cols)
assert "en_developpement" in keys
# Insérée avant « done ».
assert keys.index("en_developpement") == keys.index("done") - 1
# Renommage + mapping via le formulaire de la colonne.
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/en_developpement",
data={
"name": "🚀 Dev",
"color": "#224466",
"label": "etape:dev",
"aliases": "en-dev, dev",
"in_focus": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert bcols.names(ws_cols)["en_developpement"] == "🚀 Dev"
lookup = bcols.label_lookup(ws_cols)
assert lookup["etape:dev"] == "en_developpement"
assert lookup["en-dev"] == "en_developpement"
# Le board affiche la nouvelle colonne.
board = auth_client.get("/board/Projets/demo")
assert 'data-column="en_developpement"' in board.text
assert "🚀 Dev" in board.text
def test_admin_column_delete_migrates_cards(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "Attente client", "color": "#445566", "label": "",
"aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
assert "attente_client" in bcols.keys(ws_cols)
models.move_issue(
"Projets/demo", 5, "attente_client",
workspace_id=ws_cols, connection_id=1,
)
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/columns/attente_client/delete",
data={"target": "backlog", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303
assert "attente_client" not in bcols.keys(ws_cols)
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert any(c["issue_number"] == 5 for c in state["backlog"])
# Les colonnes système ne se suppriment pas.
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/backlog/delete",
data={"target": "todo", "csrf_token": token},
follow_redirects=False,
)
assert "backlog" in bcols.keys(ws_cols)
def test_admin_columns_reset(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "Temporaire", "color": "#445566", "label": "",
"aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
assert "temporaire" in bcols.keys(ws_cols)
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/reset",
data={"csrf_token": token},
follow_redirects=False,
)
assert bcols.keys(ws_cols) == [
"backlog", "todo", "in_progress", "qa", "review", "done",
]
# ── Mapping : adoption par alias et miroir personnalisé ──
def test_label_alias_adoption(auth_client, ws_cols, fake_gitea):
"""L'étiquette Flowdeck kanban_a_faire, déclarée alias de la
colonne todo, fait adopter À faire par le board."""
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/todo",
data={
"name": "📋 À faire", "color": "#3b82f6",
"label": "kanban:todo", "aliases": "kanban_a_faire",
"csrf_token": token,
},
follow_redirects=False,
)
fake_gitea.issues["Projets/demo"].append(
_issue(42, "Carte Flowdeck", labels=[
{"id": 950, "name": "kanban_a_faire", "color": "aaaaaa"},
])
)
payload = auth_client.get("/api/board/Projets/demo").json()
assert "42" in payload["columns"]["todo"]["html"]
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert any(c["issue_number"] == 42 for c in state["todo"])
def test_custom_label_mirror_on_move(auth_client, ws_cols, fake_gitea):
"""Déplacer une carte pose l'étiquette mappée de SA colonne."""
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "En développement", "color": "#224466",
"label": "etape:dev", "aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
token = csrf_from(auth_client)
r = auth_client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo",
"issue_number": "5",
"to_column": "en_developpement",
"csrf_token": token,
},
)
assert r.status_code == 200
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
)
names = [l["name"] for l in issue.get("labels") or []]
assert "etape:dev" in names