Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2096c9b6 | ||
|
|
e63fe39578 | ||
|
|
b7d21f80df | ||
|
|
c7711c0ca2 | ||
|
|
319619d200 | ||
|
|
0bdde28b79 | ||
|
|
4b38acdcba | ||
|
|
760fbd8c6d |
@@ -1,5 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## 0.22.0 — 2026-10-14
|
||||
|
||||
### Écran de connexion (demande de Bruno)
|
||||
|
||||
- Fini la fenêtre Basic Auth du navigateur : page **/login**
|
||||
professionnelle (carte centrée, marque dégradée, erreur
|
||||
claire, retour à la page demandée après connexion).
|
||||
- Session par **cookie signé HMAC** (7 jours, HttpOnly,
|
||||
SameSite=Lax) ; clé `KANBANTE_SESSION_SECRET` ou dérivée des
|
||||
identifiants — changer le mot de passe invalide les sessions.
|
||||
- L'en-tête **Basic reste accepté** pour les scripts ; les API
|
||||
reçoivent 401, seuls les navigateurs sont redirigés. Lien
|
||||
**⏻ Sortir** dans le pied de la barre latérale, `/logout`
|
||||
réel. Protection CSRF sur le formulaire, redirection `next`
|
||||
limitée aux chemins locaux.
|
||||
|
||||
### Barre latérale (demandes de Bruno)
|
||||
|
||||
- Le bouton **Replier** quitte le bas de la barre : c'est une
|
||||
**icône seule, en haut, à droite du bouton recherche**.
|
||||
- **Accordéon exclusif** : ouvrir une section ferme les
|
||||
autres — une seule ouverte à la fois (état mémorisé dans
|
||||
`kanbante-nav-open` ; la section de la page courante prime).
|
||||
|
||||
### Chantier B — instantanés d'arrière-plan (validé par Bruno)
|
||||
|
||||
- Un **rafraîchisseur** recalcule périodiquement les listes
|
||||
coûteuses (index des dépôts, étoiles, issues `all` **et**
|
||||
`open`, jalons — pour chaque projet actif) et les persiste
|
||||
dans SQLite (table `snapshots`, écriture à chaque réponse du
|
||||
client). `KANBANTE_SNAPSHOT_INTERVAL` (défaut 120 s, 0 = off).
|
||||
- Au démarrage, le cache du client est **hydraté** depuis les
|
||||
instantanés (servis périmés + rafraîchis en tâche de fond,
|
||||
mécanique SWR) : la première page après un redémarrage ou un
|
||||
redéploiement ne paie plus Gitea.
|
||||
- Un **webhook Gitea** rafraîchit immédiatement les
|
||||
instantanés du dépôt touché. La fraîcheur est affichée dans
|
||||
le pied de la barre (« 🕐 à l'instant / il y a N min ») :
|
||||
les données Gitea peuvent dater d'un intervalle au plus,
|
||||
le journal local reste instantané.
|
||||
- **Mesuré sur l'instance réelle** (via le proxy de Spark,
|
||||
latences gonflées) : premier chargement à froid 9,4 s sans
|
||||
instantanés → après redémarrage avec instantanés : Projets
|
||||
0,31 s, Focus 0,16 s, Triage 0,07 s, Santé 0,22 s,
|
||||
Prévisions 0,08 s.
|
||||
|
||||
## 0.21.0 — 2026-10-12
|
||||
|
||||
### Sélecteur de dépôt intelligent (Capture éclair, demande de Bruno)
|
||||
|
||||
- Le champ **Dépôt** de /capture propose les dépôts réels dès
|
||||
la frappe : liste filtrée en direct, correspondance sur les
|
||||
deux segments — « kan » suffit pour trouver
|
||||
`Projets/kanbante`, « pr/kan » aussi. Au focus, épinglés (⭐)
|
||||
et dépôts récents d'abord.
|
||||
- **Complétion Tab segment par segment**, comme un shell :
|
||||
« pr » Tab → `Projets/` (le séparateur est posé dès que le
|
||||
nom d'organisation est complet), « kan » Tab → `kanbante`.
|
||||
↑/↓ + Entrée et la souris choisissent dans la liste.
|
||||
- **Validation douce** : un nom qui n'existe pas dans Gitea est
|
||||
signalé sous le champ avant l'envoi.
|
||||
- Nouvel endpoint `GET /api/repos` : liste **exhaustive**
|
||||
(~130 dépôts chez Bruno — la palette Ctrl+K reste plafonnée
|
||||
à 60 pour son propre usage), triée épinglés → récents →
|
||||
archivés. Sélecteur dans `static/js/repo-picker.js`,
|
||||
réutilisable par d'autres formulaires.
|
||||
- Vérifié en Playwright sur les données réelles, geste exact
|
||||
de Bruno inclus : `pr` Tab → `Projets/`, `kan` Tab →
|
||||
`Projets/kanbante`.
|
||||
|
||||
## 0.20.1 — 2026-10-12
|
||||
|
||||
### Correctifs tri/regroupement du dashboard (rapportés par Bruno)
|
||||
|
||||
@@ -112,8 +112,10 @@ sudo systemctl enable --now docker
|
||||
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
|
||||
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
|
||||
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
|
||||
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis |
|
||||
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
|
||||
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
|
||||
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
|
||||
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
|
||||
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
|
||||
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
|
||||
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
|
||||
|
||||
+13
-3
@@ -8,9 +8,15 @@ Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
GITEA_TOKEN Gitea API token (read/write issues).
|
||||
KANBANTE_DB_PATH Path of the local SQLite database.
|
||||
KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth).
|
||||
KANBANTE_AUTH_PASSWORD Basic-auth password.
|
||||
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
|
||||
KANBANTE_AUTH_PASSWORD Login password.
|
||||
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
|
||||
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
|
||||
(default: derived from the credentials —
|
||||
rotating the password invalidates sessions).
|
||||
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
|
||||
refreshes (chantier B, default 120;
|
||||
0 disables the refresher).
|
||||
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
|
||||
days counts as "active" (default 365).
|
||||
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
|
||||
@@ -89,7 +95,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.20.1"
|
||||
APP_VERSION = "0.22.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -158,6 +164,8 @@ class Settings:
|
||||
briefing_include_standup: bool = False
|
||||
rules_raw: str = ""
|
||||
zombie_days: int = 30
|
||||
session_secret: str = ""
|
||||
snapshot_interval: int = 120
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -203,4 +211,6 @@ def get_settings() -> Settings:
|
||||
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
||||
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
|
||||
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
|
||||
)
|
||||
|
||||
+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.20.1"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+104
-13
@@ -64,6 +64,10 @@ class GiteaClient:
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
self._refreshing: set[str] = set()
|
||||
self._cache_gen = 0
|
||||
# Snapshot bookkeeping (chantier B): cache key -> UTC time
|
||||
# the payload was fetched from Gitea, for the freshness
|
||||
# label and the persisted `snapshots` table.
|
||||
self._snapshot_times: dict[str, str] = {}
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
@@ -112,6 +116,90 @@ class GiteaClient:
|
||||
def _cache_put(self, key: str, value: Any) -> None:
|
||||
if self.cache_ttl > 0:
|
||||
self._cache[key] = (time.monotonic(), value)
|
||||
self._persist_snapshot(key, value)
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
|
||||
|
||||
def _persist_snapshot(self, key: str, value: Any) -> None:
|
||||
"""Write-through to the SQLite snapshots table.
|
||||
|
||||
Persistence must never break a read: any failure (DB
|
||||
locked, unserializable payload) is logged and swallowed.
|
||||
"""
|
||||
try:
|
||||
from app import models
|
||||
|
||||
models.save_snapshot(key, value)
|
||||
import datetime
|
||||
|
||||
self._snapshot_times[key] = datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception as exc:
|
||||
logger.debug("snapshot persist failed for %s: %s", key, exc)
|
||||
|
||||
def hydrate_from_snapshots(self) -> int:
|
||||
"""Preload the cache from persisted snapshots (startup).
|
||||
|
||||
Entries are inserted with an already-stale timestamp: the
|
||||
SWR layer serves them instantly AND schedules a background
|
||||
refresh, so the first page after a restart renders from
|
||||
local data (tens of ms) while Gitea data catches up.
|
||||
Returns the number of entries hydrated.
|
||||
"""
|
||||
if self.cache_ttl <= 0:
|
||||
return 0
|
||||
try:
|
||||
from app import models
|
||||
|
||||
snapshots = models.load_snapshots()
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot hydration failed: %s", exc)
|
||||
return 0
|
||||
stale_at = time.monotonic() - self.cache_ttl - 1.0
|
||||
for key, (payload, updated_at) in snapshots.items():
|
||||
self._cache[key] = (stale_at, payload)
|
||||
self._snapshot_times[key] = updated_at
|
||||
if snapshots:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
|
||||
return len(snapshots)
|
||||
|
||||
def latest_snapshot_at(self) -> str | None:
|
||||
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
||||
if not self._snapshot_times:
|
||||
return None
|
||||
return max(self._snapshot_times.values())
|
||||
|
||||
async def _force(self, key: str, fetcher: Callable[[], Awaitable[Any]]) -> Any:
|
||||
"""Fetch now, bypassing freshness (background refresher)."""
|
||||
value = await fetcher()
|
||||
self._cache_put(key, value)
|
||||
return value
|
||||
|
||||
async def refresh_starred(self) -> list[dict]:
|
||||
return await self._force(
|
||||
"user:starred", lambda: self._get_all_pages("/user/starred")
|
||||
)
|
||||
|
||||
async def refresh_repo_issues(
|
||||
self, owner: str, repo: str, state: str = "all"
|
||||
) -> list[dict]:
|
||||
return await self._force(
|
||||
f"issues:{owner}/{repo}:{state}",
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
),
|
||||
)
|
||||
|
||||
async def refresh_milestones(
|
||||
self, owner: str, repo: str
|
||||
) -> list[dict]:
|
||||
return await self._force(
|
||||
f"milestones:{owner}/{repo}:open",
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state="open"
|
||||
),
|
||||
)
|
||||
|
||||
def _cache_clear(self) -> None:
|
||||
self._cache.clear()
|
||||
@@ -182,22 +270,25 @@ class GiteaClient:
|
||||
self._user = await self._get("/user")
|
||||
return self._user
|
||||
|
||||
async def _fetch_search_repos(self) -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
async def search_repos(self) -> list[dict]:
|
||||
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
||||
return await self._cached_list("search_repos", self._fetch_search_repos)
|
||||
|
||||
async def _fetch() -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
return await self._cached_list("search_repos", _fetch)
|
||||
async def refresh_search_repos(self) -> list[dict]:
|
||||
"""Force-fetch the repo index (background refresher)."""
|
||||
return await self._force("search_repos", self._fetch_search_repos)
|
||||
|
||||
async def get_repo(self, owner: str, repo: str) -> dict:
|
||||
return await self._get(f"/repos/{owner}/{repo}")
|
||||
|
||||
+196
-28
@@ -22,13 +22,21 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.responses import (
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
RedirectResponse,
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -37,6 +45,7 @@ from app.config import APP_VERSION, get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
from app import code_links as code_links_service
|
||||
from app import digest as digest_service
|
||||
from app import snapshots as snapshots_service
|
||||
from app import flow as flow_service
|
||||
from app import forecast as forecast_service
|
||||
from app import rules as rules_service
|
||||
@@ -155,6 +164,13 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(
|
||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||
)
|
||||
# Chantier B: hydrate the client cache from persisted
|
||||
# snapshots BEFORE serving, then keep them warm in the
|
||||
# background — pages never wait on Gitea when data exists.
|
||||
gitea.hydrate_from_snapshots()
|
||||
snapshot_task = asyncio.create_task(
|
||||
snapshots_service.snapshot_loop(gitea, classify_repos)
|
||||
)
|
||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||
digest_task = asyncio.create_task(_digest_scheduler())
|
||||
rules_task = asyncio.create_task(_rules_scheduler())
|
||||
@@ -179,6 +195,7 @@ async def lifespan(app: FastAPI):
|
||||
settings.capture_ntfy_topic,
|
||||
)
|
||||
yield
|
||||
snapshot_task.cancel()
|
||||
scheduler.cancel()
|
||||
digest_task.cancel()
|
||||
rules_task.cancel()
|
||||
@@ -192,20 +209,81 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
||||
gitea = GiteaClient()
|
||||
|
||||
|
||||
# ── Auth middleware ────────────────────────────────────────
|
||||
# ── Auth: login form + signed session cookie (0.22.0) ──────
|
||||
# Before 0.22.0 the browser got HTTP Basic's native prompt.
|
||||
# Browsers now use a real login page; the session rides in an
|
||||
# HMAC-signed cookie. An Authorization: Basic header is still
|
||||
# honoured for scripts and API clients. /api/health stays
|
||||
# public for Docker's HEALTHCHECK; /webhooks/gitea is
|
||||
# authenticated by its HMAC signature (Gitea cannot log in) and
|
||||
# /api/capture by its dedicated token.
|
||||
|
||||
SESSION_COOKIE = "kanbante_session"
|
||||
SESSION_DAYS = 7
|
||||
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login")
|
||||
|
||||
|
||||
def _session_secret() -> bytes:
|
||||
settings = get_settings()
|
||||
if settings.session_secret:
|
||||
return settings.session_secret.encode("utf-8")
|
||||
# Derived from the credentials: rotating the password
|
||||
# invalidates every outstanding session (documented trade-off).
|
||||
material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}"
|
||||
return hashlib.sha256(material.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def _make_session(user: str) -> str:
|
||||
exp = int(time.time()) + SESSION_DAYS * 86400
|
||||
payload = f"{user}.{exp}"
|
||||
sig = hmac.new(
|
||||
_session_secret(), payload.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{payload}.{sig}"
|
||||
|
||||
|
||||
def _session_user(request: Request) -> str | None:
|
||||
raw = request.cookies.get(SESSION_COOKIE, "")
|
||||
user, _, rest = raw.partition(".")
|
||||
exp, _, sig = rest.partition(".")
|
||||
if not user or not exp or not sig:
|
||||
return None
|
||||
expected = hmac.new(
|
||||
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not secrets.compare_digest(sig, expected):
|
||||
return None
|
||||
try:
|
||||
if int(exp) < int(time.time()):
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
if not secrets.compare_digest(user, get_settings().auth_user):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def _basic_ok(request: Request) -> bool:
|
||||
settings = get_settings()
|
||||
header = request.headers.get("authorization", "")
|
||||
if not header.lower().startswith("basic "):
|
||||
return False
|
||||
try:
|
||||
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
||||
except Exception:
|
||||
return False
|
||||
user, _, password = decoded.partition(":")
|
||||
return secrets.compare_digest(
|
||||
user, settings.auth_user
|
||||
) and secrets.compare_digest(password, settings.auth_password)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""HTTP Basic auth, enabled only when credentials are configured.
|
||||
|
||||
/api/health stays public so Docker's HEALTHCHECK (liveness) works.
|
||||
When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every
|
||||
other route answers 503: failing closed beats failing open.
|
||||
"""
|
||||
"""Session cookie (browser) or Basic header (scripts)."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
||||
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
||||
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
|
||||
path = request.url.path
|
||||
if path in _PUBLIC_PATHS or path.startswith("/static/"):
|
||||
return await call_next(request)
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
@@ -220,30 +298,82 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
status_code=503,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
ok = False
|
||||
header = request.headers.get("authorization", "")
|
||||
if header.lower().startswith("basic "):
|
||||
try:
|
||||
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
||||
except Exception:
|
||||
decoded = ""
|
||||
user, _, password = decoded.partition(":")
|
||||
ok = secrets.compare_digest(
|
||||
user, settings.auth_user
|
||||
) and secrets.compare_digest(password, settings.auth_password)
|
||||
if not ok:
|
||||
return Response(
|
||||
"Authentication required",
|
||||
if _session_user(request) is not None or _basic_ok(request):
|
||||
return await call_next(request)
|
||||
# API consumers get a plain 401 (no browser prompt, no
|
||||
# redirect they cannot follow); browsers get the login page.
|
||||
if path.startswith("/api/") or "text/html" not in request.headers.get(
|
||||
"accept", ""
|
||||
):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Authentication required"},
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="Kanbante"'},
|
||||
)
|
||||
return await call_next(request)
|
||||
target = path
|
||||
if request.url.query:
|
||||
target = f"{path}?{request.url.query}"
|
||||
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
|
||||
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
|
||||
def _safe_next(raw: str) -> str:
|
||||
"""Only local paths may be a post-login destination."""
|
||||
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
|
||||
return raw
|
||||
return "/"
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login_page(request: Request, next: str = "/"):
|
||||
if not get_settings().auth_enabled or _session_user(request) is not None:
|
||||
return RedirectResponse(_safe_next(next), status_code=303)
|
||||
return render(request, "login.html", next=_safe_next(next), error="")
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(""),
|
||||
password: str = Form(""),
|
||||
next: str = Form("/"),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return render(
|
||||
request, "login.html", status_code=403,
|
||||
next=_safe_next(next), error="Session expirée — réessaie.",
|
||||
)
|
||||
ok = secrets.compare_digest(
|
||||
username, settings.auth_user
|
||||
) and secrets.compare_digest(password, settings.auth_password)
|
||||
if not ok:
|
||||
return render(
|
||||
request, "login.html", status_code=401,
|
||||
next=_safe_next(next), error="Identifiants incorrects.",
|
||||
)
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_make_session(username),
|
||||
max_age=SESSION_DAYS * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout():
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return response
|
||||
|
||||
|
||||
# ── Live updates (SSE hub) ─────────────────────────────────
|
||||
|
||||
class LiveHub:
|
||||
@@ -330,6 +460,8 @@ def render(
|
||||
context.setdefault("csrf_token", token)
|
||||
context.setdefault("version", APP_VERSION)
|
||||
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
|
||||
context.setdefault("auth_enabled", get_settings().auth_enabled)
|
||||
context.setdefault("data_freshness", snapshots_service.freshness_label())
|
||||
html = _tpl_env.get_template(name).render(**context)
|
||||
response = HTMLResponse(html, status_code=status_code)
|
||||
if generated:
|
||||
@@ -2303,6 +2435,13 @@ async def gitea_webhook(request: Request):
|
||||
handled = "pr-merged:" + (",".join(results) if results else "none")
|
||||
|
||||
gitea.invalidate_cache()
|
||||
# Chantier B: a webhook means this repo just changed — refresh
|
||||
# its snapshots right away instead of waiting for the interval.
|
||||
if full_name and "/" in full_name:
|
||||
_owner, _, _repo = full_name.partition("/")
|
||||
asyncio.create_task(
|
||||
snapshots_service.refresh_repo_snapshots(gitea, _owner, _repo)
|
||||
)
|
||||
await hub.broadcast(
|
||||
{"repo": full_name, "event": event, "action": action, "issue": number}
|
||||
)
|
||||
@@ -3513,6 +3652,35 @@ async def api_stats():
|
||||
return get_stats()
|
||||
|
||||
|
||||
@app.get("/api/repos")
|
||||
async def api_repos():
|
||||
"""Every repo, for pickers (capture page repo selector, 0.21.0).
|
||||
|
||||
Unlike /api/palette (capped at 60 for a launcher), a picker
|
||||
must be exhaustive — Bruno has 130+ repos. Ranked for the
|
||||
empty-query case: starred first, archived last, most
|
||||
recently updated first in between.
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
active, inactive, archived = classify_repos(repos)
|
||||
try:
|
||||
starred = {r["full_name"] for r in await gitea.list_starred()}
|
||||
except Exception:
|
||||
starred = set()
|
||||
out = [
|
||||
{
|
||||
"full_name": r["full_name"],
|
||||
"starred": r["full_name"] in starred,
|
||||
"updated_at": r.get("updated_at") or "",
|
||||
"archived": bool(r.get("archived")),
|
||||
}
|
||||
for r in active + inactive + archived
|
||||
]
|
||||
out.sort(key=lambda x: x["updated_at"], reverse=True)
|
||||
out.sort(key=lambda x: (not x["starred"], x["archived"]))
|
||||
return {"repos": out}
|
||||
|
||||
|
||||
@app.get("/api/palette")
|
||||
async def api_palette(q: str = ""):
|
||||
"""Data for the Ctrl+K command palette (roadmap #10).
|
||||
|
||||
@@ -11,6 +11,7 @@ and upgrades older databases in place (position column, notes
|
||||
updated_at, cleanup of rows carrying an unknown column name).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
@@ -91,6 +92,12 @@ def init_db() -> None:
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
key TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_repo
|
||||
ON board_events(repo_full_name, issue_number, created_at);
|
||||
"""
|
||||
@@ -292,6 +299,48 @@ def set_kv(key: str, value: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) ─────────────────────────
|
||||
# Persisted copies of the Gitea list responses the pages read
|
||||
# (repos, issues, milestones, starred). The client cache is
|
||||
# hydrated from these rows at startup, so even the first page
|
||||
# after a restart renders from local data while the background
|
||||
# refresher re-fetches from Gitea.
|
||||
|
||||
def save_snapshot(key: str, payload) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO snapshots (key, payload, updated_at) "
|
||||
"VALUES (?, ?, datetime('now')) "
|
||||
"ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, "
|
||||
"updated_at = excluded.updated_at",
|
||||
(key, json.dumps(payload)),
|
||||
)
|
||||
|
||||
|
||||
def load_snapshots() -> dict:
|
||||
"""All snapshots as {key: (payload, updated_at_utc_str)}."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key, payload, updated_at FROM snapshots"
|
||||
).fetchall()
|
||||
out = {}
|
||||
for row in rows:
|
||||
try:
|
||||
out[row["key"]] = (json.loads(row["payload"]), row["updated_at"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def latest_snapshot_at() -> str | None:
|
||||
"""UTC timestamp (SQLite format) of the freshest snapshot."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(updated_at) AS latest FROM snapshots"
|
||||
).fetchone()
|
||||
return row["latest"] if row else None
|
||||
|
||||
|
||||
# ── Event journal ──────────────────────────────────────────
|
||||
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Background snapshot refresher — chantier B (0.22.0).
|
||||
|
||||
The expensive part of Kanbante is the fan-out of Gitea list
|
||||
calls behind the portfolio pages (repo index, then issues and
|
||||
milestones for every active project). Since 0.19.0 those calls
|
||||
are parallel and stale-while-revalidate, which fixed repeat
|
||||
loads, but the FIRST page after a restart still paid seconds.
|
||||
|
||||
The refresher pays that cost instead of the user: every
|
||||
KANBANTE_SNAPSHOT_INTERVAL seconds it force-refreshes the hot
|
||||
keys (repo index, starred, issues + milestones of active
|
||||
repos). Every fetch write-throughs to the SQLite `snapshots`
|
||||
table, and the client cache is hydrated from that table at
|
||||
startup — so pages read local data from the very first request
|
||||
and only ever wait on Gitea when no snapshot exists yet.
|
||||
|
||||
Freshness contract, shown in the UI: Gitea-side data may lag
|
||||
by up to one interval; a Gitea webhook refreshes the touched
|
||||
repo immediately (see refresh_repo_snapshots); anything the
|
||||
user does locally (board journal) is always instant.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from app import models
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("kanbante.snapshots")
|
||||
|
||||
_REFRESH_CONCURRENCY = 8
|
||||
|
||||
|
||||
async def refresh_repo_snapshots(gitea, owner: str, repo: str) -> None:
|
||||
"""Refresh one repo's hot keys (webhooks call this).
|
||||
|
||||
Both issue states are refreshed: most pages read "all", but
|
||||
Focus/Triage/Mes issues read "open" — a snapshot set missing
|
||||
either leaves a slow first hit on the table.
|
||||
"""
|
||||
try:
|
||||
await gitea.refresh_repo_issues(owner, repo, "all")
|
||||
await gitea.refresh_repo_issues(owner, repo, "open")
|
||||
await gitea.refresh_milestones(owner, repo)
|
||||
except Exception as exc:
|
||||
logger.debug("repo snapshot refresh failed for %s/%s: %s", owner, repo, exc)
|
||||
|
||||
|
||||
async def refresh_snapshots(gitea, classify_repos) -> int:
|
||||
"""One full refresh cycle. Returns the active-repo count."""
|
||||
repos = await gitea.refresh_search_repos()
|
||||
try:
|
||||
await gitea.refresh_starred()
|
||||
except Exception as exc:
|
||||
logger.debug("starred refresh failed: %s", exc)
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
semaphore = asyncio.Semaphore(_REFRESH_CONCURRENCY)
|
||||
|
||||
async def _one(repo: dict) -> None:
|
||||
owner, _, name = repo["full_name"].partition("/")
|
||||
async with semaphore:
|
||||
await refresh_repo_snapshots(gitea, owner, name)
|
||||
|
||||
await asyncio.gather(*(_one(repo) for repo in active))
|
||||
logger.info("snapshot refresh complete (%d active repos)", len(active))
|
||||
return len(active)
|
||||
|
||||
|
||||
async def snapshot_loop(gitea, classify_repos) -> None:
|
||||
"""Periodic refresher, started by the app lifespan.
|
||||
|
||||
Sleeps briefly first: hydration already serves the pages,
|
||||
the loop only has to keep the snapshots warm.
|
||||
"""
|
||||
interval = get_settings().snapshot_interval
|
||||
if interval <= 0:
|
||||
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||
return
|
||||
await asyncio.sleep(10)
|
||||
while True:
|
||||
try:
|
||||
await refresh_snapshots(gitea, classify_repos)
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot refresh cycle failed: %s", exc)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
def freshness_label() -> str | None:
|
||||
"""« à l'instant » / « il y a N min » from the freshest snapshot.
|
||||
|
||||
Honest freshness display (chantier B contract): the sidebar
|
||||
shows how old the Gitea data on screen can be.
|
||||
"""
|
||||
try:
|
||||
latest = models.latest_snapshot_at()
|
||||
except Exception:
|
||||
return None
|
||||
if not latest:
|
||||
return None
|
||||
try:
|
||||
fetched = datetime.datetime.strptime(
|
||||
latest, "%Y-%m-%d %H:%M:%S"
|
||||
).replace(tzinfo=datetime.timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
age = (
|
||||
datetime.datetime.now(datetime.timezone.utc) - fetched
|
||||
).total_seconds()
|
||||
if age < 90:
|
||||
return "à l'instant"
|
||||
if age < 3600:
|
||||
return f"il y a {int(age // 60)} min"
|
||||
if age < 86400:
|
||||
return f"il y a {int(age // 3600)} h"
|
||||
return f"il y a {int(age // 86400)} j"
|
||||
+38
-16
@@ -6,18 +6,6 @@
|
||||
Page active : `active_nav` (déduite du gabarit dans render()).
|
||||
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
|
||||
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
|
||||
<script>
|
||||
document.body.classList.add('with-sidebar');
|
||||
try {
|
||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
JSON.parse(localStorage.getItem('kanbante-nav-closed') || '[]')
|
||||
.forEach(function (k) {
|
||||
document.body.classList.add('nav-closed-' + k);
|
||||
});
|
||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||
</script>
|
||||
{% set nav_groups = [
|
||||
("principal", "Principal", [
|
||||
("home", "/", "🏠", "Projets"),
|
||||
@@ -42,6 +30,37 @@
|
||||
("wall", "/wall", "🖥️", "Wallboard"),
|
||||
]),
|
||||
] %}
|
||||
{% set active_group = namespace(key="principal") %}
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
{% for key, href, icon, label in links %}
|
||||
{% if active_nav == key %}{% set active_group.key = gkey %}{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<script>
|
||||
document.body.classList.add('with-sidebar');
|
||||
try {
|
||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
/* Accordéon exclusif (0.22.0) : une seule section ouverte.
|
||||
La section de la page courante prime, sinon celle
|
||||
mémorisée, sinon « principal ». */
|
||||
localStorage.removeItem('kanbante-nav-closed');
|
||||
var openGroup = {{ active_group.key | tojson }};
|
||||
var stored = localStorage.getItem('kanbante-nav-open');
|
||||
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
|
||||
if ({{ (active_nav != "") | tojson }}) {
|
||||
localStorage.setItem('kanbante-nav-open', openGroup);
|
||||
} else if (stored === '') {
|
||||
openGroup = '';
|
||||
} else if (stored !== null && groups.indexOf(stored) >= 0) {
|
||||
openGroup = stored;
|
||||
}
|
||||
groups.forEach(function (k) {
|
||||
if (k !== openGroup) document.body.classList.add('nav-closed-' + k);
|
||||
});
|
||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||
</script>
|
||||
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
|
||||
<div class="side-head">
|
||||
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
|
||||
@@ -49,6 +68,7 @@
|
||||
<span class="side-label gradient-text">Kanbante</span>
|
||||
</a>
|
||||
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
@@ -71,11 +91,13 @@
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<button type="button" class="side-collapse" id="side-collapse" aria-label="Replier ou déplier le menu">
|
||||
<span class="side-collapse-ico">«</span>
|
||||
<span class="side-label">Replier</span>
|
||||
</button>
|
||||
{% if data_freshness %}
|
||||
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
|
||||
{% endif %}
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
{% if auth_enabled %}
|
||||
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="side-backdrop" id="side-backdrop" hidden></div>
|
||||
|
||||
@@ -37,7 +37,12 @@
|
||||
<form method="post" action="/capture" class="capture-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Dépôt
|
||||
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
|
||||
<span class="repo-picker">
|
||||
<input type="text" name="repo_full_name" id="capture-repo" value="{{ capture_repo }}" placeholder="Projets/kanbante" required autocomplete="off" role="combobox" aria-expanded="false" aria-controls="repo-listbox" aria-autocomplete="list">
|
||||
<span class="repo-list" id="repo-listbox" role="listbox" hidden></span>
|
||||
</span>
|
||||
<span class="repo-hint" id="repo-hint" hidden>⚠️ Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
|
||||
<span class="repo-tip">Astuce : tape un début, <kbd>Tab</kbd> complète segment par segment (<code>pr</code> → <code>Projets/</code> → <code>kan</code> → <code>kanbante</code>), ↑↓ + Entrée choisissent dans la liste.</span>
|
||||
</label>
|
||||
<label>Titre de l'issue
|
||||
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
|
||||
@@ -84,6 +89,7 @@
|
||||
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/repo-picker.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<!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>Connexion — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<main class="login-wrap">
|
||||
<section class="login-card">
|
||||
<div class="login-brand">
|
||||
<span class="login-mark" aria-hidden="true">📋</span>
|
||||
<h1>Kanbante</h1>
|
||||
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
|
||||
</div>
|
||||
{% if error %}
|
||||
<p class="login-error" role="alert">⚠️ {{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/login" class="login-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label>Nom d'utilisateur
|
||||
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label>Mot de passe
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit" class="login-submit">Se connecter</button>
|
||||
</form>
|
||||
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.20.1"
|
||||
version = "0.22.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+99
-4
@@ -1297,11 +1297,22 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
.side-collapse {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 9px;
|
||||
color: var(--text-2); padding: 6px 10px; font-size: 0.85rem; white-space: nowrap;
|
||||
/* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la
|
||||
barre, à droite de la recherche (même gabarit qu'elle). */
|
||||
flex-shrink: 0;
|
||||
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
.side-collapse:hover { color: var(--text); }
|
||||
.side-collapse:hover {
|
||||
color: var(--text); border-color: var(--accent, #58a6ff);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
|
||||
.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
|
||||
.side-logout:hover { color: var(--text); text-decoration: none; }
|
||||
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
||||
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
||||
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
||||
@@ -1513,3 +1524,87 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
border: 1px solid var(--border, #333);
|
||||
}
|
||||
.health-back { font-size: 13.5px; }
|
||||
|
||||
/* ── Sélecteur de dépôt Capture (0.21.0) ────── */
|
||||
.repo-picker { position: relative; display: block; }
|
||||
.repo-list {
|
||||
position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 80;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface-1, #141a26);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 5px;
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.5);
|
||||
max-height: 264px; overflow-y: auto;
|
||||
}
|
||||
.repo-list[hidden] { display: none !important; }
|
||||
.repo-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 8px 10px; border-radius: 8px; cursor: pointer;
|
||||
font-weight: 400; font-size: 0.94rem;
|
||||
}
|
||||
.repo-item.active { background: var(--surface-2, #1c2330); }
|
||||
.repo-item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.repo-item-meta { color: var(--text-3, #8b94a5); font-size: 0.78rem; white-space: nowrap; }
|
||||
.repo-hint { font-weight: 400; font-size: 0.85rem; color: #f0a35e; }
|
||||
.repo-tip { font-weight: 400; font-size: 0.82rem; color: var(--text-3, #8b94a5); }
|
||||
.repo-tip kbd {
|
||||
font-size: 0.72rem; border: 1px solid var(--border); border-bottom-width: 2px;
|
||||
border-radius: 5px; padding: 0 5px; background: var(--surface);
|
||||
}
|
||||
.capture-form input.repo-unknown { border-color: #f0a35e; }
|
||||
|
||||
/* ── Écran de connexion (0.22.0) ────────────── */
|
||||
.login-body {
|
||||
min-height: 100dvh; margin: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background:
|
||||
radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%),
|
||||
radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%),
|
||||
var(--bg, #0a0f16);
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.login-wrap { width: 100%; max-width: 400px; }
|
||||
.login-card {
|
||||
background: var(--surface, #10151f);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 34px 32px 26px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.login-brand { text-align: center; margin-bottom: 22px; }
|
||||
.login-mark {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25));
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.login-brand h1 {
|
||||
margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700;
|
||||
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; }
|
||||
.login-error {
|
||||
margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
|
||||
background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45);
|
||||
color: #f5a58c; font-size: 0.9rem;
|
||||
}
|
||||
.login-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; }
|
||||
.login-form input {
|
||||
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2);
|
||||
font: inherit; font-weight: 400;
|
||||
}
|
||||
.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; }
|
||||
.login-submit {
|
||||
margin-top: 4px; padding: 12px 18px; cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7));
|
||||
color: #fff; border: none; border-radius: 10px;
|
||||
font: inherit; font-weight: 600; font-size: 1rem;
|
||||
transition: filter 0.15s, transform 0.1s;
|
||||
}
|
||||
.login-submit:hover { filter: brightness(1.12); }
|
||||
.login-submit:active { transform: scale(0.98); }
|
||||
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
|
||||
|
||||
+18
-31
@@ -43,46 +43,33 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* Accordion sections: the closed set lives in body classes
|
||||
(applied pre-paint by the bootstrap script in _header.html)
|
||||
and is persisted here on every toggle. */
|
||||
/* Accordéon EXCLUSIF (0.22.0) : une seule section ouverte
|
||||
à la fois. Ouvrir une section ferme les autres ; cliquer
|
||||
la section ouverte la referme. L'état initial vient du
|
||||
script d'amorce de _header.html (section de la page
|
||||
courante ou choix mémorisé dans kanbante-nav-open). */
|
||||
var groupBtns = document.querySelectorAll('[data-group-btn]');
|
||||
function persistClosedGroups() {
|
||||
var closed = [];
|
||||
groupBtns.forEach(function (btn) {
|
||||
if (document.body.classList.contains('nav-closed-' + btn.getAttribute('data-group-btn'))) {
|
||||
closed.push(btn.getAttribute('data-group-btn'));
|
||||
}
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('kanbante-nav-closed', JSON.stringify(closed));
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
function syncGroupBtn(btn) {
|
||||
var closed = document.body.classList.contains(
|
||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||
}
|
||||
function setOpenGroup(openKey) {
|
||||
groupBtns.forEach(function (btn) {
|
||||
var key = btn.getAttribute('data-group-btn');
|
||||
document.body.classList.toggle('nav-closed-' + key, key !== openKey);
|
||||
syncGroupBtn(btn);
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('kanbante-nav-open', openKey);
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
groupBtns.forEach(function (btn) {
|
||||
syncGroupBtn(btn);
|
||||
btn.addEventListener('click', function () {
|
||||
document.body.classList.toggle(
|
||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||
syncGroupBtn(btn);
|
||||
persistClosedGroups();
|
||||
var key = btn.getAttribute('data-group-btn');
|
||||
var isOpen = !document.body.classList.contains('nav-closed-' + key);
|
||||
setOpenGroup(isOpen ? '' : key);
|
||||
});
|
||||
});
|
||||
/* The section holding the current page is always opened. */
|
||||
var activeLink = document.querySelector('.sidebar .side-link.active');
|
||||
if (activeLink) {
|
||||
var section = activeLink.closest('.side-section');
|
||||
if (section) {
|
||||
var key = section.getAttribute('data-group');
|
||||
if (document.body.classList.contains('nav-closed-' + key)) {
|
||||
document.body.classList.remove('nav-closed-' + key);
|
||||
groupBtns.forEach(syncGroupBtn);
|
||||
persistClosedGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Kanbante — sélecteur de dépôt de la Capture éclair (0.21.0).
|
||||
*
|
||||
* Le champ Dépôt devient intelligent :
|
||||
* - suggestions des dépôts réels dès la frappe (données /api/repos,
|
||||
* épinglés et récents d'abord) ;
|
||||
* - correspondance sur les deux segments : « kan » suffit pour
|
||||
* trouver Projets/kanbante, « pr/kan » aussi ;
|
||||
* - complétion Tab segment par segment, comme un shell :
|
||||
* « pr » Tab → « Projets/ », puis « kan » Tab → « kanbante » ;
|
||||
* - ↑/↓ + Entrée, clic souris, Échap ;
|
||||
* - avertissement doux si le nom final n'existe pas dans Gitea.
|
||||
*/
|
||||
(function () {
|
||||
var input = document.getElementById('capture-repo');
|
||||
if (!input) return;
|
||||
var list = document.getElementById('repo-listbox');
|
||||
var hint = document.getElementById('repo-hint');
|
||||
var repos = null;
|
||||
var candidates = [];
|
||||
var activeIndex = -1;
|
||||
var isOpen = false;
|
||||
|
||||
function load() {
|
||||
if (repos) return Promise.resolve(repos);
|
||||
return fetch('/api/repos', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) { repos = data.repos || []; return repos; })
|
||||
.catch(function () { repos = []; return repos; });
|
||||
}
|
||||
|
||||
/* ── Correspondance ──────────────────────────────────── */
|
||||
function matchRepos(value) {
|
||||
var raw = value.trim().toLowerCase();
|
||||
if (!raw) return repos.slice(0, 12);
|
||||
var parts = raw.split('/').map(function (s) { return s.trim(); });
|
||||
var scored = [];
|
||||
repos.forEach(function (repo) {
|
||||
var full = repo.full_name.toLowerCase();
|
||||
var slash = full.indexOf('/');
|
||||
var owner = full.slice(0, slash);
|
||||
var name = full.slice(slash + 1);
|
||||
var score = null;
|
||||
if (parts.length === 1) {
|
||||
var q = parts[0];
|
||||
if (!q) score = 4;
|
||||
else if (name.indexOf(q) === 0) score = 0;
|
||||
else if (owner.indexOf(q) === 0) score = 1;
|
||||
else if (name.indexOf(q) > -1) score = 2;
|
||||
else if (full.indexOf(q) > -1) score = 3;
|
||||
} else {
|
||||
if (owner.indexOf(parts[0]) === 0 && name.indexOf(parts[1]) === 0) score = 0;
|
||||
else if (owner.indexOf(parts[0]) > -1 && name.indexOf(parts[1]) > -1) score = 1;
|
||||
}
|
||||
if (score !== null) scored.push({ repo: repo, score: score });
|
||||
});
|
||||
scored.sort(function (a, b) {
|
||||
if (a.score !== b.score) return a.score - b.score;
|
||||
if (a.repo.starred !== b.repo.starred) return a.repo.starred ? -1 : 1;
|
||||
return (b.repo.updated_at || '').localeCompare(a.repo.updated_at || '');
|
||||
});
|
||||
return scored.slice(0, 12).map(function (s) { return s.repo; });
|
||||
}
|
||||
|
||||
/* ── Rendu / ouverture ───────────────────────────────── */
|
||||
function highlight() {
|
||||
Array.prototype.forEach.call(list.children, function (item, i) {
|
||||
item.classList.toggle('active', i === activeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
list.innerHTML = '';
|
||||
candidates.forEach(function (repo, i) {
|
||||
var item = document.createElement('span');
|
||||
item.className = 'repo-item';
|
||||
item.setAttribute('role', 'option');
|
||||
var star = repo.starred ? '⭐ ' : '';
|
||||
var date = repo.updated_at ? repo.updated_at.slice(0, 10) : '';
|
||||
var nameEl = document.createElement('span');
|
||||
nameEl.className = 'repo-item-name';
|
||||
nameEl.textContent = star + repo.full_name;
|
||||
var metaEl = document.createElement('span');
|
||||
metaEl.className = 'repo-item-meta';
|
||||
metaEl.textContent = (repo.archived ? 'archivé · ' : '') + date;
|
||||
item.appendChild(nameEl);
|
||||
item.appendChild(metaEl);
|
||||
item.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
choose(repo);
|
||||
});
|
||||
item.addEventListener('mousemove', function () {
|
||||
if (activeIndex !== i) { activeIndex = i; highlight(); }
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
highlight();
|
||||
list.hidden = !isOpen || candidates.length === 0;
|
||||
input.setAttribute('aria-expanded', isOpen && candidates.length ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function refresh(openList) {
|
||||
if (!repos) return;
|
||||
candidates = matchRepos(input.value);
|
||||
if (activeIndex >= candidates.length) activeIndex = candidates.length - 1;
|
||||
isOpen = openList;
|
||||
render();
|
||||
validate();
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
render();
|
||||
}
|
||||
|
||||
function choose(repo) {
|
||||
input.value = repo.full_name;
|
||||
close();
|
||||
validate();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function validate() {
|
||||
if (!hint || !repos) return;
|
||||
var value = input.value.trim().toLowerCase();
|
||||
var exact = !value || repos.some(function (r) {
|
||||
return r.full_name.toLowerCase() === value;
|
||||
});
|
||||
hint.hidden = exact;
|
||||
input.classList.toggle('repo-unknown', !exact);
|
||||
}
|
||||
|
||||
/* ── Complétion Tab, segment par segment ─────────────── */
|
||||
function commonPrefix(strings) {
|
||||
if (!strings.length) return '';
|
||||
var prefix = strings[0];
|
||||
for (var i = 1; i < strings.length; i++) {
|
||||
while (strings[i].indexOf(prefix) !== 0) {
|
||||
prefix = prefix.slice(0, -1);
|
||||
if (!prefix) return '';
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function completeSegment() {
|
||||
if (!repos) return false;
|
||||
var value = input.value;
|
||||
var slash = value.lastIndexOf('/');
|
||||
var head = slash >= 0 ? value.slice(0, slash + 1) : '';
|
||||
var segment = slash >= 0 ? value.slice(slash + 1) : value;
|
||||
var pool = [];
|
||||
if (slash < 0) {
|
||||
var owners = {};
|
||||
repos.forEach(function (r) {
|
||||
var o = r.full_name.split('/')[0];
|
||||
if (o.toLowerCase().indexOf(segment.toLowerCase()) === 0) owners[o] = true;
|
||||
});
|
||||
pool = Object.keys(owners);
|
||||
} else {
|
||||
var ownerPart = value.slice(0, slash).toLowerCase();
|
||||
repos.forEach(function (r) {
|
||||
var parts = r.full_name.split('/');
|
||||
if (parts[0].toLowerCase() === ownerPart &&
|
||||
parts[1].toLowerCase().indexOf(segment.toLowerCase()) === 0) {
|
||||
pool.push(parts[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!pool.length) return false;
|
||||
var lcp = commonPrefix(pool);
|
||||
if (lcp.length > segment.length &&
|
||||
lcp.toLowerCase().indexOf(segment.toLowerCase()) === 0) {
|
||||
var completed = head + lcp;
|
||||
// La complétion tombe sur un nom d'organisation exact :
|
||||
// poser le séparateur dans le même Tab (comme un shell
|
||||
// qui complète un répertoire) — le geste « pr » Tab
|
||||
// donne « Projets/ », prêt pour le segment du dépôt.
|
||||
if (slash < 0 && pool.indexOf(lcp) !== -1) completed += '/';
|
||||
input.value = completed;
|
||||
refresh(true);
|
||||
return true;
|
||||
}
|
||||
// Segment d'organisation déjà complet et sans ambiguïté :
|
||||
// poser le séparateur et passer au segment du dépôt.
|
||||
if (slash < 0 && pool.length === 1 &&
|
||||
lcp.toLowerCase() === segment.toLowerCase()) {
|
||||
input.value = head + lcp + '/';
|
||||
refresh(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Événements ──────────────────────────────────────── */
|
||||
input.addEventListener('focus', function () {
|
||||
load().then(function () { refresh(true); });
|
||||
});
|
||||
input.addEventListener('input', function () {
|
||||
activeIndex = -1;
|
||||
load().then(function () { refresh(true); });
|
||||
});
|
||||
input.addEventListener('blur', function () {
|
||||
close();
|
||||
validate();
|
||||
});
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Tab') {
|
||||
if (completeSegment()) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
load().then(function () {
|
||||
if (!isOpen) { refresh(true); activeIndex = 0; highlight(); return; }
|
||||
var delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
activeIndex = Math.max(0, Math.min(candidates.length - 1, activeIndex + delta));
|
||||
highlight();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (isOpen && activeIndex >= 0 && candidates[activeIndex]) {
|
||||
e.preventDefault();
|
||||
choose(candidates[activeIndex]);
|
||||
} else if (isOpen && candidates.length === 1) {
|
||||
e.preventDefault();
|
||||
choose(candidates[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Si le champ est pré-rempli (lien ?repo= ou défaut), valider
|
||||
// dès que la liste est connue.
|
||||
if (input.value) load().then(validate);
|
||||
})();
|
||||
@@ -271,6 +271,26 @@ class FakeGitea:
|
||||
},
|
||||
]
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) : le faux reflète
|
||||
# l'interface du vrai client.
|
||||
def hydrate_from_snapshots(self):
|
||||
return 0
|
||||
|
||||
def latest_snapshot_at(self):
|
||||
return None
|
||||
|
||||
async def refresh_search_repos(self):
|
||||
return await self.search_repos()
|
||||
|
||||
async def refresh_starred(self):
|
||||
return await self.list_starred()
|
||||
|
||||
async def refresh_repo_issues(self, owner, repo, state="all"):
|
||||
return await self.get_repo_issues(owner, repo, state=state)
|
||||
|
||||
async def refresh_milestones(self, owner, repo):
|
||||
return await self.get_milestones(owner, repo)
|
||||
|
||||
async def list_starred(self):
|
||||
return [r for r in self.repos if r["full_name"] in self.starred]
|
||||
|
||||
|
||||
@@ -20,9 +20,17 @@ def test_auth_required_when_configured(monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
# Sans en-tête navigateur : 401 (comportement historique).
|
||||
assert client.get("/").status_code == 401
|
||||
# Le Basic reste accepté pour les scripts (0.22.0).
|
||||
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
|
||||
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
|
||||
# Un navigateur, lui, est envoyé vers l'écran de connexion.
|
||||
r = client.get(
|
||||
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
# Liveness stays public for Docker HEALTHCHECK.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ def test_nav_js_contract(client, fake_gitea):
|
||||
assert "localStorage" in js.text
|
||||
assert "side-backdrop" in js.text
|
||||
assert "Escape" in js.text
|
||||
assert "kanbante-nav-closed" in js.text # accordéons persistés
|
||||
assert "kanbante-nav-open" in js.text # accordéon exclusif persisté (0.22.0)
|
||||
assert "setOpenGroup" in js.text
|
||||
assert "data-group-btn" in js.text
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Palier 17 (0.21.0) — sélecteur de dépôt intelligent (Capture).
|
||||
|
||||
- GET /api/repos : liste exhaustive des dépôts pour les
|
||||
sélecteurs (la palette reste plafonnée à 60), épinglés
|
||||
d'abord, archivés en dernier.
|
||||
- La page /capture branche le sélecteur sur le champ Dépôt
|
||||
(liste filtrée + complétion Tab par segment, repo-picker.js).
|
||||
"""
|
||||
|
||||
|
||||
def test_api_repos_lists_everything_ranked(client, fake_gitea):
|
||||
fake_gitea.starred.add("Projets/vieux")
|
||||
r = client.get("/api/repos")
|
||||
assert r.status_code == 200
|
||||
repos = r.json()["repos"]
|
||||
names = [x["full_name"] for x in repos]
|
||||
# Exhaustif : actifs + inactifs + archivés du faux Gitea.
|
||||
assert names == ["Projets/vieux", "Projets/demo", "Projets/archive"] or set(names) == {
|
||||
"Projets/demo", "Projets/vieux", "Projets/archive",
|
||||
}
|
||||
# L'épinglé passe devant malgré son inactivité.
|
||||
assert names[0] == "Projets/vieux"
|
||||
by_name = {x["full_name"]: x for x in repos}
|
||||
assert by_name["Projets/vieux"]["starred"] is True
|
||||
assert by_name["Projets/demo"]["starred"] is False
|
||||
assert by_name["Projets/archive"]["archived"] is True
|
||||
|
||||
|
||||
def test_capture_page_wires_the_repo_picker(client):
|
||||
r = client.get("/capture")
|
||||
assert r.status_code == 200
|
||||
assert 'id="capture-repo"' in r.text
|
||||
assert 'id="repo-listbox"' in r.text
|
||||
assert "repo-picker.js" in r.text
|
||||
# Le contrat du formulaire ne change pas.
|
||||
assert 'name="repo_full_name"' in r.text
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Palier 18 (0.22.0) — login screen, sidebar, chantier B.
|
||||
|
||||
- Connexion par formulaire + cookie de session signé (le Basic
|
||||
reste accepté pour les scripts) ; les navigateurs sont
|
||||
redirigés vers /login, les API reçoivent 401.
|
||||
- Bouton replier dans l'en-tête de la barre latérale (icône
|
||||
seule, à droite de la recherche) ; accordéon exclusif.
|
||||
- Instantanés : persistance des réponses du client, hydratation
|
||||
au démarrage, étiquette de fraîcheur.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import models
|
||||
from app.gitea_client import GiteaClient
|
||||
from app.snapshots import freshness_label
|
||||
|
||||
|
||||
def _auth_client(monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
return TestClient(main.app)
|
||||
|
||||
|
||||
def _csrf_from(html: str) -> str:
|
||||
m = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
||||
assert m, "le formulaire de connexion doit porter le jeton CSRF"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_browser_is_redirected_to_login(monkeypatch, fake_gitea):
|
||||
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
# Les API, elles, reçoivent un 401 sec (pas de redirection).
|
||||
r = client.get("/api/repos")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_flow_and_logout(monkeypatch, fake_gitea):
|
||||
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||
page = client.get("/login")
|
||||
assert page.status_code == 200
|
||||
assert "login-card" in page.text
|
||||
token = _csrf_from(page.text)
|
||||
|
||||
bad = client.post(
|
||||
"/login",
|
||||
data={"username": "bruno", "password": "non", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert bad.status_code == 401
|
||||
assert "Identifiants incorrects" in bad.text
|
||||
|
||||
ok = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "bruno",
|
||||
"password": "motdepasse",
|
||||
"csrf_token": token,
|
||||
"next": "/focus",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert ok.status_code == 303
|
||||
assert ok.headers["location"] == "/focus"
|
||||
# La session ouvre les pages sans autre en-tête.
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
client.get("/logout")
|
||||
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
def test_login_next_is_local_only(monkeypatch, fake_gitea):
|
||||
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||
token = _csrf_from(client.get("/login").text)
|
||||
r = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "bruno",
|
||||
"password": "motdepasse",
|
||||
"csrf_token": token,
|
||||
"next": "https://evil.example/phish",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_sidebar_head_and_exclusive_accordion(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
head = r.text.split('class="side-nav"')[0]
|
||||
assert 'id="side-collapse"' in head # replier dans l'en-tête…
|
||||
assert head.index("side-search") < head.index('id="side-collapse"') # …à droite de la recherche
|
||||
assert '<span class="side-label">Replier</span>' not in r.text # icône seule
|
||||
assert "kanbante-nav-open" in r.text # accordéon exclusif (amorce)
|
||||
|
||||
|
||||
async def test_snapshot_write_through_and_hydration():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"full_name": "Projets/demo", "archived": False}]},
|
||||
headers={"X-Total-Count": "1"},
|
||||
)
|
||||
|
||||
writer = GiteaClient(
|
||||
base_url="http://gitea.test", cache_ttl=30,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
repos = await writer.search_repos()
|
||||
assert repos and repos[0]["full_name"] == "Projets/demo"
|
||||
stored = models.load_snapshots()
|
||||
assert "search_repos" in stored
|
||||
|
||||
def boom(request):
|
||||
raise AssertionError("Gitea ne doit pas être appelé : l'instantané suffit")
|
||||
|
||||
reader = GiteaClient(
|
||||
base_url="http://gitea.test", cache_ttl=30,
|
||||
transport=httpx.MockTransport(boom),
|
||||
)
|
||||
assert reader.hydrate_from_snapshots() >= 1
|
||||
served = await reader.search_repos()
|
||||
assert served[0]["full_name"] == "Projets/demo"
|
||||
assert freshness_label() == "à l'instant"
|
||||
Reference in New Issue
Block a user