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:
@@ -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]:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
Reference in New Issue
Block a user