Files
colibre/docs/superpowers/plans/2026-06-30-roadmap-votes.md
T
Colin Maudry b7ca9c1ffa docs: plan implémentation vote roadmap #94
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:30:58 +02:00

1153 lines
38 KiB
Markdown

# Vote pour les prochaines fonctionnalités (Roadmap) — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Permettre aux abonnés de voter pour les prochaines fonctionnalités depuis `/compte/roadmap`, exposer la même roadmap en lecture seule sur `/a-propos/roadmap`, et afficher le changelog du dépôt.
**Architecture:** Le « portefeuille » de votes (solde + curseur d'accumulation) vit sur la table `subscriptions` avec une accumulation paresseuse (pas de cronjob). Un nouveau package `src/roadmap/` (calqué sur `src/saved_views/`) regroupe la récupération des issues GitHub (mises en cache 1 h), le registre des votes émis (`feature_votes`), et les composants d'affichage partagés entre les deux pages.
**Tech Stack:** Python, Dash 3.4 + Dash Bootstrap Components, SQLite (`users.sqlite` via `src.auth.db.get_conn`), `httpx`, `flask_caching`.
## Global Constraints
- Importer tous les modules de l'app avec le préfixe `src.` (ex. `src.roadmap.db`), jamais `roadmap.db`.
- UI en **français**.
- Client HTTP : **`httpx`** uniquement (déjà dépendance). Ne pas ajouter `requests`.
- Cache GitHub : `@cache.memoize(timeout=3600)` (1 heure).
- **Pas** de `GITHUB_TOKEN` : appel anonyme à l'API GitHub publique.
- Dépôt GitHub cible : `ColinMaudry/decp.info`.
- Dash 3.4 : utiliser `dcc.Input` (jamais `html.Input`).
- Lancer les tests avec `uv run pytest` (pas de `source .venv/bin/activate`).
- Les fonctions DB suivent le style existant de `src/subscriptions/db.py` : pas de `conn.commit()` explicite (la connexion auto-commite), helper `_now()` pour les timestamps ISO UTC.
- `+2` votes initiaux à la fin d'essai (passage à `active`), `+1`/semaine en abonnement actif, gel hors abonnement, pas de re-crédit des `+2` au réabonnement, pas de retrait de vote.
---
## File Structure
- `src/subscriptions/db.py` (modifier) — colonnes `votes_balance` / `votes_credited_until` dans le schéma ; fonctions `credit_pending`, `spend_vote`, `freeze_votes_cursor` ; intégration freeze dans `update_from_webhook` et `set_cancelled`.
- `src/migrations.py` (modifier) — migrations `ALTER TABLE` pour les 2 colonnes (DB de prod existante).
- `src/roadmap/__init__.py` (créer) — package vide.
- `src/roadmap/db.py` (créer) — table `feature_votes` (`init_schema`), `record_vote`, `vote_counts`.
- `src/roadmap/github.py` (créer) — `fetch_roadmap_issues()` (caché 1 h).
- `src/roadmap/ui.py` (créer) — `roadmap_content`, `vote_items`, `balance_text`, `changelog_markdown`.
- `src/pages/compte_roadmap.py` (créer) — page abonné `/compte/roadmap` + callback de vote.
- `src/pages/a_propos/roadmap.py` (créer) — page publique `/a-propos/roadmap`.
- `src/pages/_compte_shell.py` (modifier) — entrée nav `roadmap`.
- `src/pages/_apropos_shell.py` (modifier) — entrée nav `roadmap`.
- `src/app.py` (modifier) — lien version → `/a-propos/roadmap` ; appel `roadmap_db.init_schema()`.
- `tests/roadmap/__init__.py`, `tests/roadmap/conftest.py` (créer) — fixtures DB.
- `tests/roadmap/test_db.py`, `tests/roadmap/test_github.py`, `tests/roadmap/test_ui.py` (créer).
- `tests/subscriptions/test_db.py` (modifier) — tests d'accumulation.
> **Note de conception :** la table `feature_votes` est créée via `init_schema()` (`CREATE TABLE IF NOT EXISTS`, idempotent, appelé au démarrage comme `src/saved_views/db.py`), pas via une migration. Les migrations restent réservées à l'ajout de colonnes sur la table `subscriptions` déjà existante en prod (précédent : `0001_add_prix_ht_to_subscriptions`).
---
## Task 1 : Colonnes de votes sur `subscriptions` + migrations
**Files:**
- Modify: `src/subscriptions/db.py` (constante `SUBSCRIPTIONS_SCHEMA`)
- Modify: `src/migrations.py` (`_MIGRATIONS`)
- Test: `tests/subscriptions/test_db.py`
**Interfaces:**
- Produces : table `subscriptions` avec colonnes `votes_balance INTEGER NOT NULL DEFAULT 0` et `votes_credited_until TEXT` (NULL par défaut), disponibles via `db.get_by_user(user_id)["votes_balance"]` / `["votes_credited_until"]`.
- [ ] **Step 1: Écrire le test qui échoue**
Ajouter dans `tests/subscriptions/test_db.py` :
```python
def test_init_schema_creates_votes_columns(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
row = db.get_by_user(uid)
assert row["votes_balance"] == 0
assert row["votes_credited_until"] is None
```
- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**
Run: `uv run pytest tests/subscriptions/test_db.py::test_init_schema_creates_votes_columns -v`
Expected: FAIL (`no such column: votes_balance` ou `KeyError`)
- [ ] **Step 3: Ajouter les colonnes au schéma**
Dans `src/subscriptions/db.py`, modifier `SUBSCRIPTIONS_SCHEMA` pour ajouter les deux colonnes juste après `trial_used` :
```python
SUBSCRIPTIONS_SCHEMA = """
CREATE TABLE IF NOT EXISTS subscriptions (
user_id INTEGER PRIMARY KEY,
frisbii_customer_handle TEXT,
frisbii_subscription_handle TEXT,
plan TEXT,
prix_ht REAL,
status TEXT,
current_period_end TEXT,
trial_used INTEGER NOT NULL DEFAULT 0,
votes_balance INTEGER NOT NULL DEFAULT 0,
votes_credited_until TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer
ON subscriptions(frisbii_customer_handle);
"""
```
- [ ] **Step 4: Lancer le test pour vérifier qu'il passe**
Run: `uv run pytest tests/subscriptions/test_db.py::test_init_schema_creates_votes_columns -v`
Expected: PASS
- [ ] **Step 5: Ajouter les migrations pour la prod**
Dans `src/migrations.py`, ajouter à la fin de `_MIGRATIONS` :
```python
(
"0003_add_votes_balance_to_subscriptions",
"ALTER TABLE subscriptions ADD COLUMN votes_balance INTEGER NOT NULL DEFAULT 0",
),
(
"0004_add_votes_credited_until_to_subscriptions",
"ALTER TABLE subscriptions ADD COLUMN votes_credited_until TEXT",
),
```
(`apply_pending()` tolère déjà `duplicate column name` sur une DB fraîche où le schéma contient déjà la colonne.)
- [ ] **Step 6: Commit**
```bash
git add src/subscriptions/db.py src/migrations.py tests/subscriptions/test_db.py
git commit -m "feat: colonnes de votes sur subscriptions #94"
```
---
## Task 2 : Accumulation paresseuse (`credit_pending`) + dépense (`spend_vote`)
**Files:**
- Modify: `src/subscriptions/db.py`
- Test: `tests/subscriptions/test_db.py`
**Interfaces:**
- Consumes : colonnes de la Task 1.
- Produces :
- `INITIAL_VOTES = 2`, `WEEK_SECONDS = 7 * 24 * 3600`
- `credit_pending(user_id: int) -> int` — crédite les votes acquis et renvoie le solde courant.
- `spend_vote(user_id: int) -> bool` — débite 1 vote si le solde le permet, renvoie `True` si débité.
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter dans `tests/subscriptions/test_db.py` (les imports `datetime, timedelta, timezone` y sont déjà) :
```python
def _activate(uid, cursor_iso=None):
"""Met l'abonnement en statut actif avec un curseur d'accumulation donné."""
db.get_conn().execute(
"UPDATE subscriptions SET status = 'active', votes_credited_until = ? "
"WHERE user_id = ?",
(cursor_iso, uid),
)
def test_credit_pending_grants_initial_two_on_first_active(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None)
balance = db.credit_pending(uid)
assert balance == 2
assert db.get_by_user(uid)["votes_credited_until"] is not None
def test_credit_pending_is_idempotent_same_day(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None)
db.credit_pending(uid)
assert db.credit_pending(uid) == 2 # aucun crédit supplémentaire
def test_credit_pending_adds_one_vote_per_full_week(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
fifteen_days_ago = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat()
_activate(uid, cursor_iso=fifteen_days_ago)
# 15 jours = 2 semaines pleines
assert db.credit_pending(uid) == 2
def test_credit_pending_no_credit_when_not_active(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple") # statut 'pending'
assert db.credit_pending(uid) == 0
def test_spend_vote_decrements_when_balance_positive(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None)
db.credit_pending(uid) # solde = 2
assert db.spend_vote(uid) is True
assert db.get_by_user(uid)["votes_balance"] == 1
def test_spend_vote_refused_when_balance_zero(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None)
# solde reste 0 tant que credit_pending n'est pas appelé
assert db.spend_vote(uid) is False
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/subscriptions/test_db.py -k "credit_pending or spend_vote" -v`
Expected: FAIL (`AttributeError: module ... has no attribute 'credit_pending'`)
- [ ] **Step 3: Implémenter `credit_pending` et `spend_vote`**
Dans `src/subscriptions/db.py`, après `_now()` ajouter les constantes, puis ajouter les fonctions (par exemple après `has_used_trial`). Adapter l'import en tête du fichier : `from datetime import datetime, timedelta, timezone`.
```python
INITIAL_VOTES = 2
WEEK_SECONDS = 7 * 24 * 3600
def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None:
get_conn().execute(
"UPDATE subscriptions SET votes_balance = ?, votes_credited_until = ?, "
"updated_at = ? WHERE user_id = ?",
(balance, cursor_iso, _now(), user_id),
)
def credit_pending(user_id: int) -> int:
"""Crédite paresseusement les votes acquis et renvoie le solde courant.
+2 à la première activation (fin d'essai), puis +1 par semaine pleine tant
que l'abonnement est actif. Idempotent : ne crédite que des semaines pleines.
"""
row = get_by_user(user_id)
if row is None:
return 0
balance = row["votes_balance"] or 0
if row["status"] != "active":
return balance
now = datetime.now(timezone.utc)
cursor = row["votes_credited_until"]
if cursor is None:
balance += INITIAL_VOTES
_set_votes(user_id, balance, now.isoformat())
return balance
cur = datetime.fromisoformat(cursor)
weeks = int((now - cur).total_seconds() // WEEK_SECONDS)
if weeks > 0:
balance += weeks
new_cursor = cur + timedelta(seconds=weeks * WEEK_SECONDS)
_set_votes(user_id, balance, new_cursor.isoformat())
return balance
def spend_vote(user_id: int) -> bool:
"""Débite 1 vote si le solde le permet. Renvoie True si un vote a été débité."""
cur = get_conn().execute(
"UPDATE subscriptions SET votes_balance = votes_balance - 1, updated_at = ? "
"WHERE user_id = ? AND votes_balance > 0",
(_now(), user_id),
)
return cur.rowcount > 0
```
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
Run: `uv run pytest tests/subscriptions/test_db.py -k "credit_pending or spend_vote" -v`
Expected: PASS (6 tests)
- [ ] **Step 5: Commit**
```bash
git add src/subscriptions/db.py tests/subscriptions/test_db.py
git commit -m "feat: accumulation paresseuse et dépense de votes #94"
```
---
## Task 3 : Gel au désabonnement / réabonnement
**Files:**
- Modify: `src/subscriptions/db.py` (`freeze_votes_cursor`, `update_from_webhook`, `set_cancelled`)
- Test: `tests/subscriptions/test_db.py`
**Interfaces:**
- Consumes : `credit_pending`, `get_by_customer` (existant).
- Produces : `freeze_votes_cursor(user_id: int) -> None` — remet le curseur à maintenant si déjà posé (jamais de re-crédit des +2).
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter dans `tests/subscriptions/test_db.py` :
```python
def test_reactivation_resets_cursor_without_regranting(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None)
db.credit_pending(uid) # +2, curseur posé
# désabonnement
db.update_from_webhook("decpinfo-1", "sub_1", "cancelled", _future())
# période sans abonnement simulée : on recule artificiellement le curseur
old_cursor = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
db.get_conn().execute(
"UPDATE subscriptions SET votes_credited_until = ? WHERE user_id = ?",
(old_cursor, uid),
)
# réabonnement
db.update_from_webhook("decpinfo-1", "sub_1", "active", _future())
row = db.get_by_user(uid)
assert row["votes_balance"] == 2 # pas de re-crédit des +2
# le curseur a été remis ~à maintenant → pas de crédit du gap de 30 jours
assert db.credit_pending(uid) == 2
def test_trial_to_active_does_not_reset_then_grants_two(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
db.update_from_webhook("decpinfo-1", "sub_1", "trial", _future())
db.update_from_webhook("decpinfo-1", "sub_1", "active", _future())
# fin d'essai : credit_pending accorde les +2 initiaux
assert db.credit_pending(uid) == 2
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/subscriptions/test_db.py -k "reactivation or trial_to_active" -v`
Expected: FAIL (re-crédit des +2 ou crédit du gap, selon le cas)
- [ ] **Step 3: Implémenter `freeze_votes_cursor` et brancher webhook/cancel**
Dans `src/subscriptions/db.py`, ajouter :
```python
def freeze_votes_cursor(user_id: int) -> None:
"""Réactivation après une période sans abonnement : repart de maintenant.
Ne fait rien si le curseur est NULL (première activation jamais atteinte) :
les +2 initiaux restent gérés par credit_pending. Ne re-crédite jamais.
"""
row = get_by_user(user_id)
if row is None or row["votes_credited_until"] is None:
return
now = _now()
get_conn().execute(
"UPDATE subscriptions SET votes_credited_until = ?, updated_at = ? "
"WHERE user_id = ?",
(now, now, user_id),
)
```
Modifier `update_from_webhook` pour banquer avant un gel et réinitialiser le curseur à la réactivation :
```python
def update_from_webhook(
customer_handle: str,
subscription_handle: str | None,
status: str,
current_period_end: str | None,
) -> None:
prev = get_by_customer(customer_handle)
if prev is not None and prev["status"] == "active" and status != "active":
credit_pending(prev["user_id"]) # banque les semaines acquises avant gel
trial_flag = 1 if status in _ACCESS_STATUSES else 0
get_conn().execute(
"UPDATE subscriptions SET "
"frisbii_subscription_handle = COALESCE(?, frisbii_subscription_handle), "
"status = ?, current_period_end = ?, "
"trial_used = max(trial_used, ?), updated_at = ? "
"WHERE frisbii_customer_handle = ?",
(
subscription_handle,
status,
current_period_end,
trial_flag,
_now(),
customer_handle,
),
)
if prev is not None and prev["status"] != "active" and status == "active":
freeze_votes_cursor(prev["user_id"])
```
Modifier `set_cancelled` pour banquer les semaines acquises avant de figer le statut :
```python
def set_cancelled(user_id: int, current_period_end: str | None) -> None:
credit_pending(user_id) # banque les semaines pleines acquises (statut encore actif)
get_conn().execute(
"UPDATE subscriptions SET status = 'cancelled', current_period_end = ?, "
"updated_at = ? WHERE user_id = ?",
(current_period_end, _now(), user_id),
)
```
- [ ] **Step 4: Lancer toute la suite `test_db` pour vérifier qu'elle passe (pas de régression)**
Run: `uv run pytest tests/subscriptions/test_db.py -v`
Expected: PASS (tous les tests, anciens et nouveaux)
- [ ] **Step 5: Commit**
```bash
git add src/subscriptions/db.py tests/subscriptions/test_db.py
git commit -m "feat: gel des votes au désabonnement/réabonnement #94"
```
---
## Task 4 : Registre des votes émis (`src/roadmap/db.py`)
**Files:**
- Create: `src/roadmap/__init__.py`
- Create: `src/roadmap/db.py`
- Create: `tests/roadmap/__init__.py`
- Create: `tests/roadmap/conftest.py`
- Test: `tests/roadmap/test_db.py`
**Interfaces:**
- Produces :
- `init_schema() -> None`
- `record_vote(user_id: int, issue_number: int) -> None`
- `vote_counts() -> dict[int, int]``{numéro_issue: nombre_total_de_votes}`.
- [ ] **Step 1: Créer le package et les fixtures de test**
Créer `src/roadmap/__init__.py` (vide) et `tests/roadmap/__init__.py` (vide).
Créer `tests/roadmap/conftest.py` :
```python
import pytest
# Dash minimal pour que register_page() fonctionne dans les tests de pages de ce
# répertoire (CONFIG peuplé). Instancié une seule fois, ici, avant tout import de page.
from dash import Dash as _Dash
_Dash(__name__, use_pages=True, pages_folder="", assets_folder="assets")
@pytest.fixture
def users_db_path(monkeypatch, tmp_path):
from src.auth.db import reset_conn_for_tests
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
reset_conn_for_tests()
yield db_path
reset_conn_for_tests()
```
- [ ] **Step 2: Écrire les tests qui échouent**
Créer `tests/roadmap/test_db.py` :
```python
from src.auth import db as auth_db
from src.roadmap import db as roadmap_db
def _make_user(email="u@ex.fr"):
auth_db.init_schema()
return auth_db.create_user(email, "hash")
def test_init_schema_creates_feature_votes(users_db_path):
roadmap_db.init_schema()
conn = auth_db.get_conn()
tables = {
row[0]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
assert "feature_votes" in tables
def test_record_vote_and_counts(users_db_path):
roadmap_db.init_schema()
uid = _make_user()
roadmap_db.record_vote(uid, 42)
roadmap_db.record_vote(uid, 42) # vote multiple autorisé
roadmap_db.record_vote(uid, 7)
assert roadmap_db.vote_counts() == {42: 2, 7: 1}
def test_vote_counts_empty(users_db_path):
roadmap_db.init_schema()
assert roadmap_db.vote_counts() == {}
```
- [ ] **Step 3: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/roadmap/test_db.py -v`
Expected: FAIL (`ModuleNotFoundError` ou `no such table`)
- [ ] **Step 4: Implémenter `src/roadmap/db.py`**
```python
from datetime import datetime, timezone
from src.auth.db import get_conn
SCHEMA = """
CREATE TABLE IF NOT EXISTS feature_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_feature_votes_issue
ON feature_votes(issue_number);
"""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_schema() -> None:
get_conn().executescript(SCHEMA)
def record_vote(user_id: int, issue_number: int) -> None:
get_conn().execute(
"INSERT INTO feature_votes (user_id, issue_number, created_at) "
"VALUES (?, ?, ?)",
(user_id, issue_number, _now()),
)
def vote_counts() -> dict[int, int]:
rows = get_conn().execute(
"SELECT issue_number, COUNT(*) FROM feature_votes GROUP BY issue_number"
).fetchall()
return {row[0]: row[1] for row in rows}
```
- [ ] **Step 5: Lancer les tests pour vérifier qu'ils passent**
Run: `uv run pytest tests/roadmap/test_db.py -v`
Expected: PASS (3 tests)
- [ ] **Step 6: Wirer `init_schema` au démarrage**
Dans `src/app.py`, à côté de l'appel existant `saved_views_db.init_schema()` (~ligne 110), ajouter :
```python
from src.roadmap import db as roadmap_db # noqa: E402
roadmap_db.init_schema()
```
- [ ] **Step 7: Commit**
```bash
git add src/roadmap/__init__.py src/roadmap/db.py src/app.py \
tests/roadmap/__init__.py tests/roadmap/conftest.py tests/roadmap/test_db.py
git commit -m "feat: registre des votes feature_votes #94"
```
---
## Task 5 : Récupération des issues GitHub (`src/roadmap/github.py`)
**Files:**
- Create: `src/roadmap/github.py`
- Test: `tests/roadmap/test_github.py`
**Interfaces:**
- Produces : `fetch_roadmap_issues() -> dict[str, list[dict]]` avec les clés `"en_cours"` et `"au_vote"` ; chaque élément = `{"number": int, "title": str, "html_url": str}`. Caché 1 h.
- [ ] **Step 1: Écrire les tests qui échouent**
Créer `tests/roadmap/test_github.py` :
```python
from src.roadmap import github
class _FakeResp:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
_ISSUES = [
{
"number": 10,
"title": "Feature en cours",
"html_url": "https://github.com/ColinMaudry/decp.info/issues/10",
"labels": [{"name": "en cours"}],
},
{
"number": 20,
"title": "Feature au vote",
"html_url": "https://github.com/ColinMaudry/decp.info/issues/20",
"labels": [{"name": "mis au vote"}],
},
{
"number": 30,
"title": "Issue sans label pertinent",
"html_url": "https://github.com/ColinMaudry/decp.info/issues/30",
"labels": [{"name": "bug"}],
},
{
"number": 40,
"title": "Une PR déguisée en issue",
"html_url": "https://github.com/ColinMaudry/decp.info/pull/40",
"labels": [{"name": "mis au vote"}],
"pull_request": {"url": "..."},
},
]
def test_fetch_roadmap_issues_filters_by_label(monkeypatch):
monkeypatch.setattr(github.httpx, "get", lambda *a, **k: _FakeResp(_ISSUES))
result = github.fetch_roadmap_issues.uncached()
assert [i["number"] for i in result["en_cours"]] == [10]
assert [i["number"] for i in result["au_vote"]] == [20] # PR #40 exclue
assert result["en_cours"][0] == {
"number": 10,
"title": "Feature en cours",
"html_url": "https://github.com/ColinMaudry/decp.info/issues/10",
}
```
> `@cache.memoize` expose la fonction non-cachée via l'attribut `.uncached`, ce qui permet de tester la logique sans dépendre de l'état du cache.
- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**
Run: `uv run pytest tests/roadmap/test_github.py -v`
Expected: FAIL (`ModuleNotFoundError: src.roadmap.github`)
- [ ] **Step 3: Implémenter `src/roadmap/github.py`**
```python
import httpx
from src.utils.cache import cache
GITHUB_REPO = "ColinMaudry/decp.info"
_LABEL_KEYS = {"en cours": "en_cours", "mis au vote": "au_vote"}
@cache.memoize(timeout=3600)
def fetch_roadmap_issues() -> dict[str, list[dict]]:
"""Issues ouvertes du dépôt portant un label de roadmap, regroupées par label.
Appel anonyme à l'API GitHub publique. Mis en cache 1 h.
"""
resp = httpx.get(
f"https://api.github.com/repos/{GITHUB_REPO}/issues",
params={"state": "open", "per_page": 100},
headers={"Accept": "application/vnd.github+json"},
timeout=10,
)
resp.raise_for_status()
result: dict[str, list[dict]] = {"en_cours": [], "au_vote": []}
for issue in resp.json():
if "pull_request" in issue:
continue # l'endpoint /issues inclut les PR
labels = {lbl["name"] for lbl in issue.get("labels", [])}
for label, key in _LABEL_KEYS.items():
if label in labels:
result[key].append(
{
"number": issue["number"],
"title": issue["title"],
"html_url": issue["html_url"],
}
)
return result
```
- [ ] **Step 4: Lancer le test pour vérifier qu'il passe**
Run: `uv run pytest tests/roadmap/test_github.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/roadmap/github.py tests/roadmap/test_github.py
git commit -m "feat: récupération cachée des issues roadmap GitHub #94"
```
---
## Task 6 : Composants d'affichage partagés (`src/roadmap/ui.py`)
**Files:**
- Create: `src/roadmap/ui.py`
- Test: `tests/roadmap/test_ui.py`
**Interfaces:**
- Consumes : `src.roadmap.github.fetch_roadmap_issues`, `src.roadmap.db.vote_counts`.
- Produces :
- `balance_text(balance: int) -> str`
- `vote_items(au_vote: list[dict], counts: dict[int, int], editable: bool) -> list` — items de `dbc.ListGroup` triés par votes décroissants.
- `changelog_markdown() -> dcc.Markdown`
- `roadmap_content(editable: bool, balance: int | None = None) -> html.Div` — section complète (en cours + au vote + changelog), avec bandeau de solde si `editable` et `balance` fourni.
- [ ] **Step 1: Écrire les tests qui échouent**
Créer `tests/roadmap/test_ui.py` :
```python
from dash import dcc, html
from src.roadmap import ui
def test_balance_text_singular_plural():
assert ui.balance_text(1) == "Il te reste 1 vote."
assert ui.balance_text(3) == "Il te reste 3 votes."
def test_vote_items_sorted_by_count_desc():
au_vote = [
{"number": 1, "title": "A", "html_url": "u1"},
{"number": 2, "title": "B", "html_url": "u2"},
]
counts = {1: 1, 2: 5}
items = ui.vote_items(au_vote, counts, editable=False)
# le plus voté (numéro 2) vient en premier
assert "B" in str(items[0])
assert "A" in str(items[1])
def test_vote_items_buttons_only_when_editable():
au_vote = [{"number": 1, "title": "A", "html_url": "u1"}]
assert "Voter" in str(ui.vote_items(au_vote, {1: 0}, editable=True))
assert "Voter" not in str(ui.vote_items(au_vote, {1: 0}, editable=False))
def test_changelog_markdown_returns_component():
comp = ui.changelog_markdown()
assert isinstance(comp, dcc.Markdown)
assert "##" in comp.children # le CHANGELOG.md contient des titres markdown
def test_roadmap_content_renders(monkeypatch):
monkeypatch.setattr(
ui.github,
"fetch_roadmap_issues",
lambda: {
"en_cours": [{"number": 9, "title": "En cours X", "html_url": "u9"}],
"au_vote": [{"number": 5, "title": "Au vote Y", "html_url": "u5"}],
},
)
monkeypatch.setattr(ui.roadmap_db, "vote_counts", lambda: {5: 3})
content = ui.roadmap_content(editable=True, balance=2)
s = str(content)
assert isinstance(content, html.Div)
assert "En cours X" in s
assert "Au vote Y" in s
assert "Il te reste 2 votes." in s
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/roadmap/test_ui.py -v`
Expected: FAIL (`ModuleNotFoundError: src.roadmap.ui`)
- [ ] **Step 3: Implémenter `src/roadmap/ui.py`**
```python
from pathlib import Path
import dash_bootstrap_components as dbc
from dash import dcc, html
from src.roadmap import db as roadmap_db
from src.roadmap import github
_CHANGELOG_PATH = Path(__file__).resolve().parents[2] / "CHANGELOG.md"
def balance_text(balance: int) -> str:
mot = "vote" if balance == 1 else "votes"
return f"Il te reste {balance} {mot}."
def changelog_markdown() -> dcc.Markdown:
try:
text = _CHANGELOG_PATH.read_text(encoding="utf-8")
except OSError:
text = "_Changelog indisponible._"
return dcc.Markdown(text)
def _vote_item(issue: dict, count: int, editable: bool):
mot = "vote" if count == 1 else "votes"
children = [
html.A(issue["title"], href=issue["html_url"], target="_blank"),
html.Span(f" — {count} {mot}", className="text-muted ms-1"),
]
if editable:
children.append(
dbc.Button(
"Voter",
id={"type": "roadmap-vote", "index": issue["number"]},
size="sm",
color="primary",
className="ms-2",
)
)
return dbc.ListGroupItem(children)
def vote_items(au_vote: list[dict], counts: dict[int, int], editable: bool) -> list:
ordered = sorted(au_vote, key=lambda i: counts.get(i["number"], 0), reverse=True)
if not ordered:
return [dbc.ListGroupItem("Aucune fonctionnalité au vote pour le moment.")]
return [_vote_item(i, counts.get(i["number"], 0), editable) for i in ordered]
def _en_cours_items(en_cours: list[dict]) -> list:
if not en_cours:
return [dbc.ListGroupItem("Rien en cours pour le moment.")]
return [
dbc.ListGroupItem(html.A(i["title"], href=i["html_url"], target="_blank"))
for i in en_cours
]
def roadmap_content(editable: bool, balance: int | None = None) -> html.Div:
try:
issues = github.fetch_roadmap_issues()
except Exception:
issues = {"en_cours": [], "au_vote": []}
counts = roadmap_db.vote_counts()
body: list = []
if editable and balance is not None:
body.append(
dbc.Alert(balance_text(balance), color="info", id="roadmap-balance")
)
body.append(html.H3("En cours", className="mt-3"))
body.append(dbc.ListGroup(_en_cours_items(issues["en_cours"]), className="mb-4"))
body.append(html.H3("Au vote"))
body.append(
dbc.ListGroup(
vote_items(issues["au_vote"], counts, editable),
id="roadmap-vote-list",
className="mb-4",
)
)
body.append(html.Hr())
body.append(html.H3("Changelog"))
body.append(changelog_markdown())
return html.Div(body)
```
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
Run: `uv run pytest tests/roadmap/test_ui.py -v`
Expected: PASS (5 tests)
- [ ] **Step 5: Commit**
```bash
git add src/roadmap/ui.py tests/roadmap/test_ui.py
git commit -m "feat: composants d'affichage de la roadmap #94"
```
---
## Task 7 : Page abonné `/compte/roadmap` + navigation + vote
**Files:**
- Create: `src/pages/compte_roadmap.py`
- Modify: `src/pages/_compte_shell.py` (`SECTIONS`)
- Test: `tests/roadmap/test_compte_roadmap.py`
**Interfaces:**
- Consumes : `account_guard`, `account_shell` (`src.pages._compte_shell`) ; `credit_pending`, `spend_vote` (`src.subscriptions.db`) ; `record_vote`, `vote_counts` (`src.roadmap.db`) ; `fetch_roadmap_issues` (`src.roadmap.github`) ; `roadmap_content`, `vote_items`, `balance_text` (`src.roadmap.ui`).
- [ ] **Step 1: Ajouter l'entrée de navigation abonné**
Dans `src/pages/_compte_shell.py`, ajouter une entrée à la liste `SECTIONS` (après `"vues"`) :
```python
{
"key": "roadmap",
"label": "Roadmap",
"href": "/compte/roadmap",
"require_subscription": True,
},
```
- [ ] **Step 2: Écrire le test qui échoue**
Créer `tests/roadmap/test_compte_roadmap.py` (le Dash partagé vient de `conftest.py`) :
```python
def test_module_imports_and_registers():
from src.pages import compte_roadmap
assert callable(compte_roadmap.layout)
assert callable(compte_roadmap.cast_vote)
```
> On ne teste pas `layout()` directement : il dépend de `current_user` et d'un
> contexte de requête Flask. La logique métier (accumulation, dépense, rendu) est
> déjà couverte par les Tasks 2, 3 et 6. La vérification de bout en bout est faite
> manuellement (section « Vérification finale »).
- [ ] **Step 3: Lancer le test pour vérifier qu'il échoue**
Run: `uv run pytest tests/roadmap/test_compte_roadmap.py -v`
Expected: FAIL (`ModuleNotFoundError: src.pages.compte_roadmap`)
- [ ] **Step 4: Implémenter `src/pages/compte_roadmap.py`**
```python
from dash import ALL, Input, Output, callback, ctx, no_update, register_page
from flask_login import current_user
from src.pages._compte_shell import account_guard, account_shell
from src.roadmap import db as roadmap_db
from src.roadmap import github
from src.roadmap import ui as roadmap_ui
from src.subscriptions import db as subs_db
register_page(
__name__,
path="/compte/roadmap",
title="Roadmap | decp.info",
name="Roadmap",
description="Votez pour les prochaines fonctionnalités de decp.info.",
)
def layout(**_):
guard = account_guard("/compte/roadmap", require_subscription=True)
if guard is not None:
return guard
balance = subs_db.credit_pending(current_user.id)
return account_shell(
"roadmap", roadmap_ui.roadmap_content(editable=True, balance=balance)
)
@callback(
Output("roadmap-vote-list", "children"),
Output("roadmap-balance", "children"),
Input({"type": "roadmap-vote", "index": ALL}, "n_clicks"),
prevent_initial_call=True,
)
def cast_vote(n_clicks):
if not ctx.triggered_id or not any(n_clicks):
return no_update, no_update
issue_number = ctx.triggered_id["index"]
if subs_db.spend_vote(current_user.id):
roadmap_db.record_vote(current_user.id, issue_number)
balance = subs_db.credit_pending(current_user.id)
issues = github.fetch_roadmap_issues()
counts = roadmap_db.vote_counts()
items = roadmap_ui.vote_items(issues["au_vote"], counts, editable=True)
return items, roadmap_ui.balance_text(balance)
```
- [ ] **Step 5: Lancer le test pour vérifier qu'il passe**
Run: `uv run pytest tests/roadmap/test_compte_roadmap.py -v`
Expected: PASS
- [ ] **Step 6: Vérifier la non-régression du shell compte**
Run: `uv run pytest tests/test_compte_shell.py -v`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add src/pages/compte_roadmap.py src/pages/_compte_shell.py \
tests/roadmap/test_compte_roadmap.py
git commit -m "feat: page abonné /compte/roadmap avec vote #94"
```
---
## Task 8 : Page publique `/a-propos/roadmap` + navigation
**Files:**
- Create: `src/pages/a_propos/roadmap.py`
- Modify: `src/pages/_apropos_shell.py` (`SECTIONS`)
- Test: `tests/roadmap/test_apropos_roadmap.py`
**Interfaces:**
- Consumes : `apropos_shell` (`src.pages._apropos_shell`) ; `roadmap_content` (`src.roadmap.ui`).
- [ ] **Step 1: Ajouter l'entrée de navigation publique**
Dans `src/pages/_apropos_shell.py`, ajouter à `SECTIONS` (après `"abonnement"`) :
```python
{
"key": "roadmap",
"label": "Roadmap",
"href": "/a-propos/roadmap",
},
```
- [ ] **Step 2: Écrire le test qui échoue**
Créer `tests/roadmap/test_apropos_roadmap.py` (le Dash partagé vient de `conftest.py`) :
```python
def test_public_layout_renders_read_only(monkeypatch):
from src.roadmap import ui as roadmap_ui
monkeypatch.setattr(
roadmap_ui.github,
"fetch_roadmap_issues",
lambda: {"en_cours": [], "au_vote": [
{"number": 1, "title": "Feature publique", "html_url": "u1"}
]},
)
monkeypatch.setattr(roadmap_ui.roadmap_db, "vote_counts", lambda: {1: 4})
from src.pages.a_propos import roadmap
layout = roadmap.layout()
s = str(layout)
assert "Feature publique" in s
assert "Voter" not in s # lecture seule : aucun bouton
```
- [ ] **Step 3: Lancer le test pour vérifier qu'il échoue**
Run: `uv run pytest tests/roadmap/test_apropos_roadmap.py -v`
Expected: FAIL (`ModuleNotFoundError: src.pages.a_propos.roadmap`)
- [ ] **Step 4: Implémenter `src/pages/a_propos/roadmap.py`**
```python
from dash import register_page
from src.pages._apropos_shell import apropos_shell
from src.roadmap import ui as roadmap_ui
from src.utils.seo import META_CONTENT
register_page(
__name__,
path="/a-propos/roadmap",
title="Roadmap | À propos | decp.info",
description="Les prochaines fonctionnalités de decp.info, soumises au vote des abonnés.",
image_url=META_CONTENT["image_url"],
)
def layout(**_):
return apropos_shell("roadmap", roadmap_ui.roadmap_content(editable=False))
```
- [ ] **Step 5: Lancer le test pour vérifier qu'il passe**
Run: `uv run pytest tests/roadmap/test_apropos_roadmap.py -v`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/pages/a_propos/roadmap.py src/pages/_apropos_shell.py \
tests/roadmap/test_apropos_roadmap.py
git commit -m "feat: page publique /a-propos/roadmap (lecture seule) #94"
```
---
## Task 9 : Lien du numéro de version → `/a-propos/roadmap`
**Files:**
- Modify: `src/app.py` (~ligne 195)
**Interfaces:** aucune (changement de présentation).
- [ ] **Step 1: Modifier le lien**
Dans `src/app.py`, remplacer le `href` GitHub du numéro de version :
```python
html.A(
version,
href="/a-propos/roadmap",
)
```
- [ ] **Step 2: Vérifier que l'app démarre et que le lien est correct**
Run: `uv run python -c "import src.app"`
Expected: import sans erreur.
Run: `git grep -n 'href="/a-propos/roadmap"' src/app.py`
Expected: une ligne correspondante.
- [ ] **Step 3: Commit**
```bash
git add src/app.py
git commit -m "feat: le lien de version pointe vers /a-propos/roadmap #94"
```
---
## Vérification finale
- [ ] **Suite complète des tests roadmap + subscriptions**
Run: `uv run pytest tests/roadmap tests/subscriptions tests/test_compte_shell.py -v`
Expected: PASS
- [ ] **Vérification manuelle (optionnelle, nécessite réseau + abonnement)**
Lancer `uv run python run.py`, se connecter avec un compte abonné, visiter `/compte/roadmap` : vérifier le bandeau de solde, les sections « En cours » / « Au vote », le bouton « Voter » (qui décrémente le solde et incrémente le décompte), et le changelog. Visiter `/a-propos/roadmap` en navigation privée : mêmes listes, décomptes visibles, aucun bouton. Cliquer le numéro de version près du logo → arrive sur `/a-propos/roadmap`.