feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
CI / test (push) Successful in 2m17s
- Table board_columns par espace (clé immuable, nom/couleur/
ordre libres, étiquette mappée + alias, drapeau Focus) ;
définitions semées paresseusement depuis les valeurs
historiques — sans personnalisation, comportement identique.
- Service app/board_columns.py : résolution, mapping
étiquette→colonne (alias compris, ex. kanban_a_faire de
Flowdeck), colonnes Focus/Wallboard.
- Board, API, adoption, miroirs d'étiquettes, déplacements,
capture, santé, focus, wall, dashboard branchés sur les
colonnes de l'espace ; validations dynamiques dans models
(move_issue, set_column_order) et rules.
- Administration → Colonnes : ajouter (avant Terminé),
renommer, réordonner, mapper, supprimer avec migration des
cartes vers une colonne choisie, réinitialiser ; bouton
🧱 Colonnes sur le board ; journal d'audit.
- Backlog et Terminé : colonnes système non supprimables.
- 254 tests verts (7 nouveaux, palier 20) ; smoke réel contre
le vrai Gitea : création, affichage au board, suppression.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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"),
|
||||
]),
|
||||
] %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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] }}">
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user