diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b07b4..d3ad534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`) 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 diff --git a/app/board_columns.py b/app/board_columns.py new file mode 100644 index 0000000..c658fa0 --- /dev/null +++ b/app/board_columns.py @@ -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:``. 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)) diff --git a/app/config.py b/app/config.py index f13ff15..20f4f52 100644 --- a/app/config.py +++ b/app/config.py @@ -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: diff --git a/app/digest.py b/app/digest.py index 17241e5..3f42977 100644 --- a/app/digest.py +++ b/app/digest.py @@ -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, ) diff --git a/app/main.py b/app/main.py index 9c0c99d..d4922dd 100644 --- a/app/main.py +++ b/app/main.py @@ -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:) 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: diff --git a/app/models.py b/app/models.py index 09169ee..02c7bc9 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/rules.py b/app/rules.py index ee4ada5..d8f6f13 100644 --- a/app/rules.py +++ b/app/rules.py @@ -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 ( diff --git a/app/templates/_header.html b/app/templates/_header.html index a3a5fac..ac96a8e 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -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"), ]), ] %} diff --git a/app/templates/admin_columns.html b/app/templates/admin_columns.html new file mode 100644 index 0000000..9af03c6 --- /dev/null +++ b/app/templates/admin_columns.html @@ -0,0 +1,99 @@ + + + + + + + Colonnes — Administration — Kanbante + + + + {% include "_header.html" %} + +
+
+

🧱 Colonnes du board

+

Espace : {{ workspace.name }} (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 mapping étiquette ↔ colonne. La clé d'une colonne ne change jamais (les cartes y sont rattachées) ; le nom affiché, lui, est libre.

+
+ {% if msg %}

✅ {{ msg }}

{% endif %} + {% if err %}

⚠️ {{ err }}

{% endif %} + +
+

Comment marche le mapping

+

+ Miroir : quand une carte entre dans une colonne, son étiquette mappée est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).
+ Adoption : si une issue porte exactement une é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.
+ Alias : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck kanban_a_faire sur la colonne À faire.
+ Focus : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard. +

+
+ + {% for c in columns %} +
+

+ + {{ c.name }} + {{ c.key }} · {{ counts[c.key] }} carte(s) dans cet espace + {% if c.kind == 'backlog' %}système — défaut des cartes neuves{% endif %} + {% if c.kind == 'done' %}système — issues fermées{% endif %} +

+
+ + + + {% if c.kind == 'flow' %} + + + + {% endif %} + +
+

+

+ + + +
+
+ + + +
+ {% if c.kind == 'flow' %} +
+ + + +
+ {% endif %} +

+
+ {% endfor %} + +
+

➕ Nouvelle colonne

+
+ + + + + + +
+
+ +
+

↩️ Réinitialiser

+

Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.

+
+ + +
+
+
+ + diff --git a/app/templates/board.html b/app/templates/board.html index b84f5cc..88a8fec 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -53,6 +53,7 @@ 📥 Triage du backlog + {% if is_admin or workspace_role == 'admin' %}🧱 Colonnes{% endif %}
@@ -62,7 +63,7 @@
- {% for col in ['backlog','todo','in_progress','qa','review','done'] %} + {% for col in column_keys %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 8094c84..0c93539 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -146,7 +146,7 @@ {% endif %} {% if full in stats %}
- {% 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 %} diff --git a/pyproject.toml b/pyproject.toml index e7f2a18..9991675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_palier20.py b/tests/test_palier20.py new file mode 100644 index 0000000..85f8b8f --- /dev/null +++ b/tests/test_palier20.py @@ -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