feat(abonnement): page /compte/abonnement + accès premium réel (#90)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
This commit is contained in:
Colin Maudry
2026-06-25 19:08:22 +02:00
parent 3d1cb69463
commit d3025e323b
4 changed files with 212 additions and 10 deletions
+4 -1
View File
@@ -39,8 +39,11 @@ SECTIONS = [
def current_user_has_subscription() -> bool: def current_user_has_subscription() -> bool:
"""Stub : à brancher sur la facturation (issue #73).""" from src.subscriptions import db
if not current_user.is_authenticated:
return False return False
return db.has_active_subscription(current_user.id)
def visible_sections(has_subscription: bool) -> list[dict]: def visible_sections(has_subscription: bool) -> list[dict]:
+158 -8
View File
@@ -1,6 +1,9 @@
from dash import html, register_page import dash_bootstrap_components as dbc
from dash import dcc, html, register_page
from flask_login import current_user
from src.pages._compte_shell import account_guard, account_shell from src.pages._compte_shell import account_guard, account_shell
from src.subscriptions import db, plans
register_page( register_page(
__name__, __name__,
@@ -10,16 +13,163 @@ register_page(
description="Gestion de votre abonnement decp.info.", description="Gestion de votre abonnement decp.info.",
) )
_PLAN_KEYS = ("simple", "soutien")
def layout(**_):
def _csrf_input(index: str):
return dcc.Input(
type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token"
)
def _plan_card(meta: dict, trial: int | None, trial_used: bool):
if trial_used:
badge = html.Div(
"Sans période d'essai (déjà utilisée) — débit immédiat",
className="text-muted mb-2",
)
elif trial:
badge = html.Div(
f"{trial} jours d'essai gratuit", className="text-success mb-2"
)
else:
badge = None
return dbc.Card(
dbc.CardBody(
[
html.H4(meta["label"]),
html.P(f"{meta['prix_ht']} € HT / mois"),
html.P(meta["description"]),
badge,
html.Form(
method="POST",
action="/subscriptions/subscribe",
children=[
_csrf_input(f"subscribe-{meta['key']}"),
dcc.Input(type="hidden", name="plan", value=meta["key"]),
html.Button(
"S'abonner", type="submit", className="btn btn-primary"
),
],
),
]
),
className="mb-3",
)
def _plan_cards(trial_used=False, trial_for=plans.trial_days):
cards = []
for key in _PLAN_KEYS:
meta = plans.plan_meta(key)
if meta:
cards.append(_plan_card(meta, trial_for(key), trial_used))
return dbc.Row([dbc.Col(c, md=6) for c in cards])
def _explainer():
return html.Div(
[
html.H4("À quoi servent les abonnements"),
html.Ul(
[
html.Li("Abonnement Frisbii (solution de paiement) : 50 €"),
html.Li("Serveur Scaleway : 40 €"),
html.Li("Espace de coworking : 250 €"),
html.Li("Salaire médian : 3 840 €"),
]
),
html.H4("Ce que les abonnements de soutien permettraient"),
html.Ul(
[
html.Li(
"Rédaction d'études à partir des données, par exemple sur les "
"acheteurs dont les données sont introuvables et les raisons de "
"cette non-publication."
),
html.Li(
"Coordination des bonnes volontés souhaitant militer pour une "
"législation plus exigeante sur la transparence de la commande "
"publique."
),
]
),
]
)
def _active_view(row):
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
end = row["current_period_end"]
blocks = [html.H3(meta["label"]), html.P(f"Statut : {row['status']}")]
if row["status"] == "trial":
blocks.append(
dbc.Alert(
f"Essai gratuit jusqu'au {end}, puis débit automatique.", color="info"
)
)
elif row["status"] == "cancelled":
blocks.append(
dbc.Alert(f"Abonnement résilié, actif jusqu'au {end}.", color="warning")
)
else:
blocks.append(html.P(f"Prochain renouvellement : {end}"))
if row["status"] in ("trial", "active"):
blocks.append(
html.Form(
method="POST",
action="/subscriptions/cancel",
children=[
_csrf_input("cancel"),
html.Button(
"Résilier", type="submit", className="btn btn-outline-danger"
),
],
)
)
return html.Div(blocks)
def _feedback(query):
msgs = {
"succes": ("Merci, votre abonnement est en cours d'activation.", "success"),
"annule": ("Paiement annulé.", "secondary"),
}
out = []
if query.get("paiement") in msgs:
text, color = msgs[query["paiement"]]
out.append(dbc.Alert(text, color=color))
if query.get("resiliation") == "ok":
out.append(dbc.Alert("Votre abonnement a été résilié.", color="info"))
if query.get("error") == "frisbii":
out.append(
dbc.Alert(
"Une erreur est survenue avec le service de paiement.", color="danger"
)
)
return out
def layout(**query):
guard = account_guard("/compte/abonnement", require_subscription=False) guard = account_guard("/compte/abonnement", require_subscription=False)
if guard is not None: if guard is not None:
return guard return guard
contenu = html.Div( row = db.get_by_user(current_user.id) if current_user.is_authenticated else None
[ has_access = (
html.H2("Abonnement"), db.has_active_subscription(current_user.id)
html.P("La gestion de l'abonnement arrive bientôt."), if current_user.is_authenticated
] else False
) )
return account_shell("abonnement", contenu)
trial_used = (
db.has_used_trial(current_user.id) if current_user.is_authenticated else False
)
body = [html.H2("Abonnement"), *_feedback(query)]
if has_access and row is not None:
body.append(_active_view(row))
else:
body.extend([_plan_cards(trial_used=trial_used), html.Hr(), _explainer()])
return account_shell("abonnement", html.Div(body))
+6
View File
@@ -2,6 +2,12 @@ import json as _json
import pytest import pytest
# Initialise un Dash minimal pour que register_page() fonctionne dans les tests
# unitaires de pages (pas besoin du serveur complet — juste que CONFIG soit peuplé).
from dash import Dash as _Dash
_Dash(__name__, assets_folder="assets")
class FakeResponse: class FakeResponse:
def __init__(self, status_code: int, payload=None): def __init__(self, status_code: int, payload=None):
@@ -0,0 +1,43 @@
def test_plan_cards_present_when_no_subscription(monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
from src.pages import compte_abonnement
cards = compte_abonnement._plan_cards(trial_for=lambda key: 2)
text = str(cards)
assert "Abonnement simple" in text
assert "Abonnement de soutien" in text
assert "2 jours d'essai gratuit" in text
def test_plan_cards_no_trial_when_trial_used(monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
from src.pages import compte_abonnement
text = str(compte_abonnement._plan_cards(trial_used=True, trial_for=lambda key: 2))
assert "Sans période d'essai" in text
assert "2 jours d'essai gratuit" not in text
def test_active_view_shows_cancel(monkeypatch):
from src.pages import compte_abonnement
row = {
"plan": "simple",
"status": "active",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
view = compte_abonnement._active_view(row)
assert "Résilier" in str(view)
def test_active_view_trial_banner(monkeypatch):
from src.pages import compte_abonnement
row = {
"plan": "simple",
"status": "trial",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
assert "Essai gratuit" in str(compte_abonnement._active_view(row))