Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
38 KiB
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), jamaisroadmap.db. - UI en français.
- Client HTTP :
httpxuniquement (déjà dépendance). Ne pas ajouterrequests. - 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(jamaishtml.Input). - Lancer les tests avec
uv run pytest(pas desource .venv/bin/activate). - Les fonctions DB suivent le style existant de
src/subscriptions/db.py: pas deconn.commit()explicite (la connexion auto-commite), helper_now()pour les timestamps ISO UTC. +2votes initiaux à la fin d'essai (passage àactive),+1/semaine en abonnement actif, gel hors abonnement, pas de re-crédit des+2au réabonnement, pas de retrait de vote.
File Structure
src/subscriptions/db.py(modifier) — colonnesvotes_balance/votes_credited_untildans le schéma ; fonctionscredit_pending,spend_vote,freeze_votes_cursor; intégration freeze dansupdate_from_webhooketset_cancelled.src/migrations.py(modifier) — migrationsALTER TABLEpour les 2 colonnes (DB de prod existante).src/roadmap/__init__.py(créer) — package vide.src/roadmap/db.py(créer) — tablefeature_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 navroadmap.src/pages/_apropos_shell.py(modifier) — entrée navroadmap.src/app.py(modifier) — lien version →/a-propos/roadmap; appelroadmap_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_votesest créée viainit_schema()(CREATE TABLE IF NOT EXISTS, idempotent, appelé au démarrage commesrc/saved_views/db.py), pas via une migration. Les migrations restent réservées à l'ajout de colonnes sur la tablesubscriptionsdé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(constanteSUBSCRIPTIONS_SCHEMA) - Modify:
src/migrations.py(_MIGRATIONS) - Test:
tests/subscriptions/test_db.py
Interfaces:
-
Produces : table
subscriptionsavec colonnesvotes_balance INTEGER NOT NULL DEFAULT 0etvotes_credited_until TEXT(NULL par défaut), disponibles viadb.get_by_user(user_id)["votes_balance"]/["votes_credited_until"]. -
Step 1: Écrire le test qui échoue
Ajouter dans tests/subscriptions/test_db.py :
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 :
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 :
(
"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
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 * 3600credit_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, renvoieTruesi 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à) :
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_pendingetspend_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.
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
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 :
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_cursoret brancher webhook/cancel
Dans src/subscriptions/db.py, ajouter :
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 :
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 :
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_dbpour 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
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() -> Nonerecord_vote(user_id: int, issue_number: int) -> Nonevote_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 :
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 :
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
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_schemaau démarrage
Dans src/app.py, à côté de l'appel existant saved_views_db.init_schema() (~ligne 110), ajouter :
from src.roadmap import db as roadmap_db # noqa: E402
roadmap_db.init_schema()
- Step 7: Commit
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 :
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.memoizeexpose 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
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
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) -> strvote_items(au_vote: list[dict], counts: dict[int, int], editable: bool) -> list— items dedbc.ListGrouptriés par votes décroissants.changelog_markdown() -> dcc.Markdownroadmap_content(editable: bool, balance: int | None = None) -> html.Div— section complète (en cours + au vote + changelog), avec bandeau de solde sieditableetbalancefourni.
-
Step 1: Écrire les tests qui échouent
Créer tests/roadmap/test_ui.py :
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
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
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") :
{
"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) :
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 decurrent_useret 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
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
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") :
{
"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) :
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
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
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 :
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
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.